Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61295b9c25 | |||
| 63e68756ac | |||
| 5c45629495 | |||
| dd71b644c0 | |||
| de97f5f0fb | |||
| 4f7746926c | |||
| 1106ba5c45 | |||
| b69588f14d | |||
| 1f9236bb16 | |||
| 411595ed90 | |||
| 839426a2cc | |||
| 3acdb8b729 | |||
| 8c760d4a2d | |||
| 78e9463825 | |||
| b2d4589949 | |||
| b2a334f99b | |||
| 7cb36a5c3d | |||
| e33502956c | |||
| 652bbfe12b | |||
| 6422472ef3 | |||
| 0a061af582 | |||
| f655508f88 | |||
| 1b83ec9952 | |||
| 5d705307c1 | |||
| 2174e91c48 | |||
| 700d6f9130 | |||
| 5a00f8b8fb | |||
| 05d75db0cf | |||
| f327171e9b | |||
| d4b3fa2ae5 | |||
| 353a9a27a9 | |||
| ef09338098 | |||
| ece9ac48a8 | |||
| 5672757e23 | |||
| f4754045c1 | |||
| 874893900d | |||
| 81bc72f86a | |||
| 250702de59 | |||
| 7d88ddc9d5 | |||
| e6c90a2346 | |||
| 425a7cb623 | |||
| f9d243a5af | |||
| cf7e295f35 | |||
| 1eb9d8667a | |||
| bde37af2bb | |||
| d3fc15ddd9 | |||
| e5e18ef269 | |||
| 3b828ab184 | |||
| 2957ad724c | |||
| b6f211ebe6 | |||
| 3e71a00b12 | |||
| d512cd2ac1 | |||
| 1c9c9c79c2 | |||
| 7ed6962bab | |||
| 4dfe827344 |
@@ -24,6 +24,54 @@ concurrency:
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
dedupe-check:
|
||||
name: Dedup Check - skip PR tests when covered by push pipeline
|
||||
runs-on: ci-l1
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
skip_tests: ${{ steps.dedupe.outputs.skip_tests }}
|
||||
reason: ${{ steps.dedupe.outputs.reason }}
|
||||
steps:
|
||||
- name: Decide test dedup
|
||||
id: dedupe
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
echo "skip_tests=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=push-event-tests-required" >> $GITHUB_OUTPUT
|
||||
echo "push 事件:测试照跑(部署链路门禁必需)"
|
||||
exit 0
|
||||
fi
|
||||
# 情形1:PR 已合并(合并瞬间/合并后触发的 PR run)-> 全量测试由 push 流水线承接
|
||||
MERGED=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('merged') else 'false')" || echo false)
|
||||
if [ "$MERGED" = "true" ]; then
|
||||
echo "skip_tests=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=pr-merged-push-pipeline-covers" >> $GITHUB_OUTPUT
|
||||
echo "::warning::PR #${PR_NUMBER} 已合并,测试由合并后 push 流水线承接,PR 侧测试类 job 跳过"
|
||||
exit 0
|
||||
fi
|
||||
# 情形2:同一 head_sha 已有在跑/排队的 push 流水线(rebase/ff 合并竞态)
|
||||
DUP=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${HEAD_SHA}&per_page=30" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); runs=d if isinstance(d,list) else d.get('workflow_runs',d.get('runs',[])); hit=[r for r in runs if r.get('event')=='push' and r.get('status') in ('in_progress','queued','waiting','pending')]; print('true' if hit else 'false')" || echo false)
|
||||
if [ "$DUP" = "true" ]; then
|
||||
echo "skip_tests=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=duplicate-push-run-active" >> $GITHUB_OUTPUT
|
||||
echo "::warning::同一 head_sha ${HEAD_SHA:0:8} 已有 push 流水线在跑,PR 侧测试类 job 跳过"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip_tests=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=no-duplicate" >> $GITHUB_OUTPUT
|
||||
echo "无重复 push 流水线,PR 侧测试照跑"
|
||||
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
runs-on: ci-l2
|
||||
@@ -79,9 +127,14 @@ jobs:
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-code-quality:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Code Quality
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -94,6 +147,14 @@ jobs:
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-codequality-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-codequality-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -169,6 +230,8 @@ jobs:
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-type-check:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Type Check (mypy)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
@@ -244,6 +307,8 @@ jobs:
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-migration:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Migration (alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
@@ -323,12 +388,14 @@ jobs:
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Unit Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
USE_IN_MEMORY_DB: 'true'
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
@@ -347,6 +414,14 @@ jobs:
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_install_ffmpeg.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-unittests-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Run unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
@@ -391,9 +466,10 @@ jobs:
|
||||
name: Integration Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 30
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- dedupe-check
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
@@ -457,8 +533,8 @@ jobs:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs: check-frontend-only
|
||||
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
if: needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -519,8 +595,8 @@ jobs:
|
||||
name: Frontend Unit Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -531,6 +607,13 @@ jobs:
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
- name: Install frontend dependencies (vitest only, with retry)
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -667,18 +750,7 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker: 始终用普通docker build(基础镜像已预装全部依赖,无需buildx)
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
echo "Worker: 使用普通docker build"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "PR Build successful (worker, no buildx)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "PR Build attempt $i/3"
|
||||
@@ -696,14 +768,14 @@ jobs:
|
||||
done
|
||||
echo
|
||||
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
- name: Builder cache note
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
# 持久 builder (ci-builder-persist) 跨 job 共享,不删除不 prune;
|
||||
# 残留容器/卷由宿主机 /usr/local/bin/ci-docker-cleanup.sh 兜底清理
|
||||
docker buildx ls | head -5
|
||||
echo "Persistent builder kept warm for next job"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -729,11 +801,49 @@ jobs:
|
||||
[ -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
|
||||
|
||||
check-push-paths:
|
||||
name: Check push changed paths
|
||||
runs-on: ci-l2
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
outputs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Check changed paths
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash scripts/ci/ci_push_paths.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
needs: check-push-paths
|
||||
if: |
|
||||
github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') && (
|
||||
(matrix.service == 'web' && needs.check-push-paths.outputs.skip_frontend != 'true') ||
|
||||
(matrix.service != 'web' && needs.check-push-paths.outputs.skip_backend != 'true')
|
||||
)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -797,23 +907,16 @@ jobs:
|
||||
echo "Cache mode: read-only"
|
||||
fi
|
||||
|
||||
- name: Setup buildx builder
|
||||
if: matrix.service != 'worker'
|
||||
- name: Ensure persistent buildx builder
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
if ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
run: bash scripts/ci/ensure_persistent_builder.sh
|
||||
|
||||
- name: Pre-build worker base image (fallback if not exist)
|
||||
- name: Pre-pull worker base image (fallback build if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
@@ -821,11 +924,13 @@ jobs:
|
||||
|
||||
echo "检查 Worker 基础镜像..."
|
||||
if docker pull "$BASE_IMAGE" 2>/dev/null; then
|
||||
echo "✅ 基础镜像已存在"
|
||||
echo "✅ 基础镜像已存在(buildkit 可直接命中)"
|
||||
else
|
||||
echo "⚠️ 基础镜像不存在,本地构建(fallback)..."
|
||||
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
|
||||
echo "✅ Worker 基础镜像本地构建完成"
|
||||
echo "⚠️ 基础镜像不存在,用持久 builder 构建并推送(fallback)..."
|
||||
docker buildx build --builder ci-builder-persist \
|
||||
-f infra/docker/worker-base.Dockerfile \
|
||||
-t "$BASE_IMAGE" --push .
|
||||
echo "✅ Worker 基础镜像构建推送完成"
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service_display }} image
|
||||
@@ -833,48 +938,34 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}"
|
||||
IMAGE_FULL="${REGISTRY}/${{ matrix.image_name }}"
|
||||
IMAGE_TAG="${IMAGE_FULL}:${GITHUB_SHA}"
|
||||
# 同时推分支 tag,作为未重建镜像 retag 的稳定来源
|
||||
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
# Worker: plain docker build(基础镜像已预装全部依赖,无需 buildx)
|
||||
echo "=== Worker: plain docker build ==="
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" --build-arg APP_VERSION="${GITHUB_SHA}" .
|
||||
docker push "${IMAGE_TAG}"
|
||||
echo "✅ Worker image pushed: ${IMAGE_TAG}"
|
||||
else
|
||||
# API/Web: buildx with registry cache
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 ==="
|
||||
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker build 失败(尝试 $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "下次重试将使用 --no-cache"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
- name: Cleanup buildx builder
|
||||
if: matrix.service != 'worker' && always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
|
||||
docker buildx rm ci-builder 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
|
||||
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker build 失败(尝试 $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "下次重试将使用 --no-cache"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -902,16 +993,100 @@ jobs:
|
||||
[ -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
|
||||
|
||||
retag-staging-skipped:
|
||||
name: Retag skipped Staging ${{ matrix.service_display }} Image
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- check-push-paths
|
||||
- build-staging
|
||||
if: |
|
||||
github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') && (
|
||||
(matrix.service == 'web' && needs.check-push-paths.outputs.skip_frontend == 'true') ||
|
||||
(matrix.service != 'web' && needs.check-push-paths.outputs.skip_backend == 'true')
|
||||
)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: api
|
||||
service_display: API
|
||||
image_name: xiaoxia-saas-api
|
||||
- service: worker
|
||||
service_display: Worker
|
||||
image_name: xiaoxia-saas-worker
|
||||
- service: web
|
||||
service_display: Web
|
||||
image_name: xiaoxia-saas-web
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Docker login to Registry
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
|
||||
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "Docker login 失败($i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Retag previous branch image to new SHA
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
bash scripts/ci/retag_skipped_image.sh \
|
||||
"${REGISTRY}/${{ matrix.image_name }}" \
|
||||
"${GITHUB_SHA}" \
|
||||
"${GITHUB_REF_NAME}"
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Retag Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy Staging (Watchtower auto-deploy)
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: deploy-staging-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
needs:
|
||||
- check-push-paths
|
||||
- build-staging
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
- retag-staging-skipped
|
||||
# 显式 success() 状态检查:上游 build/retag 被路径过滤 if 跳过(skipped)时不阻塞本 job;
|
||||
# 上游真正失败时仍然阻断(act_runner 对无状态函数的 if 隐式包 success(),纯 skipped 也会连带跳过)
|
||||
if: success() && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -1062,7 +1237,7 @@ jobs:
|
||||
name: Staging E2E Tests
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 15
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
if: success() && (github.ref_name == 'develop' || github.ref_name == 'main')
|
||||
needs: deploy-staging
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -1109,7 +1284,7 @@ jobs:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 10
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
if: success() && (github.ref_name == 'develop' || github.ref_name == 'main')
|
||||
needs: deploy-staging
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -1491,7 +1666,7 @@ jobs:
|
||||
|
||||
acr-cleanup:
|
||||
name: ACR Image Cleanup
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- deploy-staging
|
||||
@@ -1817,4 +1992,4 @@ jobs:
|
||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add unique index on asset_libraries(project_id, kind)
|
||||
|
||||
Revision ID: 058_uq_asset_lib_project_kind
|
||||
Revises: 057_title_config
|
||||
Create Date: 2026-08-30
|
||||
|
||||
同一项目下同 kind 的素材库业务上唯一(前端 getOrCreate 语义、TTS 保存自动建库)。
|
||||
加唯一索引兜底并发创建竞态,避免重复素材库。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "058_uq_asset_lib_project_kind"
|
||||
down_revision = "057_title_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 建唯一索引前清洗历史重复:同 (project_id, kind) 只保留 created_at 最新的一条。
|
||||
# project_id 为 NULL 的系统级行不参与去重(NULL 在唯一索引中互不冲突)。
|
||||
op.execute("""
|
||||
DELETE FROM asset_libraries
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY project_id, kind
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM asset_libraries
|
||||
WHERE project_id IS NOT NULL
|
||||
) t
|
||||
WHERE t.rn > 1
|
||||
)
|
||||
""")
|
||||
# 与 model 的 UniqueConstraint 定义保持一致(pg_constraint + pg_index 同时注册),
|
||||
# 避免 Alembic autogenerate 检测到 schema drift
|
||||
op.create_unique_constraint(
|
||||
"uq_asset_libraries_project_kind",
|
||||
"asset_libraries",
|
||||
["project_id", "kind"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("uq_asset_libraries_project_kind", "asset_libraries", type_="unique")
|
||||
@@ -26,6 +26,7 @@ from app.schemas.asset import (
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from app.services.asset_segment_tracker import compute_asset_availability
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
@@ -35,6 +36,23 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _asset_availability_fields(item) -> dict:
|
||||
"""视频素材返回余量四字段;非视频/无时长/异常时返回 None + usable=True(零影响)。"""
|
||||
try:
|
||||
info = compute_asset_availability(item)
|
||||
except Exception:
|
||||
logger.warning("计算素材余量失败,按可用处理: asset_id=%s", getattr(item, "id", "?"), exc_info=True)
|
||||
info = None
|
||||
if info is None:
|
||||
return {
|
||||
"used_duration": None,
|
||||
"available_duration": None,
|
||||
"used_ratio": None,
|
||||
"usable": True,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
# 生成签名文件 URL(用于视频播放 / 文件下载)
|
||||
file_url = None
|
||||
@@ -79,6 +97,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
created_at=format_utc_datetime(item.created_at),
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
tag_ids=getattr(item, "tag_ids", []),
|
||||
**_asset_availability_fields(item),
|
||||
)
|
||||
|
||||
|
||||
@@ -569,13 +588,35 @@ def smart_match_assets(
|
||||
kind=None,
|
||||
)
|
||||
|
||||
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
|
||||
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
|
||||
filtered_results = []
|
||||
for r in results:
|
||||
try:
|
||||
avail = compute_asset_availability(r.asset)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"smart-match 余量计算失败,按可用处理: asset_id=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
exc_info=True,
|
||||
)
|
||||
avail = None
|
||||
if avail is not None and not avail["usable"]:
|
||||
logger.info(
|
||||
"smart-match 排除已用尽素材: asset_id=%s name=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(r.asset, "name", ""),
|
||||
)
|
||||
continue
|
||||
filtered_results.append(r)
|
||||
|
||||
items = [
|
||||
SmartMatchItem(
|
||||
asset=_to_asset_response(r.asset),
|
||||
score=r.score,
|
||||
breakdown=r.breakdown,
|
||||
)
|
||||
for r in results
|
||||
for r in filtered_results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""封面生成路由 — Generation 模块.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退)
|
||||
|
||||
挂载路径: /api/v1/generation/generate-cover
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
@@ -24,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
|
||||
@@ -51,6 +55,14 @@ class GenerateCoverRequest(BaseModel):
|
||||
default=None,
|
||||
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
|
||||
)
|
||||
generated_video_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。",
|
||||
)
|
||||
video_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
@@ -121,8 +133,6 @@ def _persist_cover_frame(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
storage.upload_file(
|
||||
@@ -140,6 +150,106 @@ def _persist_cover_frame(
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task_id)
|
||||
if videos:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]:
|
||||
"""将 storage_key 或完整 URL 转换为可访问的裸 URL。"""
|
||||
if not storage_key:
|
||||
return None
|
||||
try:
|
||||
if storage_key.startswith("http"):
|
||||
url = storage_key
|
||||
else:
|
||||
storage_svc = get_shared_storage_service()
|
||||
url = storage_svc.get_url(storage_key)
|
||||
if url:
|
||||
url = re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning("[封面生成] storage_key 转 URL 失败: key=%s err=%s", storage_key, e)
|
||||
return None
|
||||
|
||||
|
||||
def _endpoint_host(value: str) -> str:
|
||||
"""从 endpoint / URL 字符串中安全提取主机名(兼容有无 scheme 两种配置)。"""
|
||||
v = (value or "").strip().lower()
|
||||
if not v:
|
||||
return ""
|
||||
if "://" in v:
|
||||
return (urlparse(v).hostname or "").lower()
|
||||
# 无 scheme:去掉可能的端口(host:port),urlparse 补 // 以正确解析
|
||||
return (urlparse("//" + v).hostname or "").lower()
|
||||
|
||||
|
||||
def _is_private_or_reserved_host(host: str) -> bool:
|
||||
"""判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。
|
||||
|
||||
使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。
|
||||
"""
|
||||
h = host.strip().lower()
|
||||
if h in {"localhost", "0.0.0.0", "::", "::1"}:
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address(h)
|
||||
# is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、
|
||||
# ::1、fc00::/7、fe80::/10 等全部私有/保留段
|
||||
return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_trusted_media_url(url: str) -> bool:
|
||||
"""校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。
|
||||
|
||||
用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL,
|
||||
因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not host:
|
||||
return False
|
||||
# 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定)
|
||||
if _is_private_or_reserved_host(host):
|
||||
return False
|
||||
# 允许:自家 OSS bucket 域名(<bucket>.<endpoint>)或 endpoint 自身及其子域
|
||||
try:
|
||||
storage_svc = get_shared_storage_service()
|
||||
trusted_hosts = set()
|
||||
public_base = getattr(storage_svc, "public_url", "") or ""
|
||||
h1 = _endpoint_host(public_base)
|
||||
if h1:
|
||||
trusted_hosts.add(h1)
|
||||
h2 = _endpoint_host(getattr(storage_svc, "endpoint", "") or "")
|
||||
if h2:
|
||||
trusted_hosts.add(h2)
|
||||
for trusted in trusted_hosts:
|
||||
if host == trusted or host.endswith("." + trusted):
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True)
|
||||
return False
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
@@ -149,12 +259,16 @@ def generate_cover(
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
"""AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
1. 优先使用前端传入的 generation_task_id 定位最终成片任务,
|
||||
或自动查找 plan 关联的已完成最终成片任务(is_preview=False)
|
||||
2. 回退:从预览片段获取视频 URL(兼容旧流程)
|
||||
3. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
4. 帧图下载后上传到 OSS covers/ 路径
|
||||
|
||||
MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
@@ -182,27 +296,111 @@ def generate_cover(
|
||||
)
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
# ── 查找用于抽帧的视频 URL ────────────────────────────────────────
|
||||
# 优先级:
|
||||
# 0. 请求体显式传入的 generation_task_id(最终成片任务)
|
||||
# 1. plan.config.rendered_storage_key
|
||||
# 2. plan.config.generation_task_id 对应的任务
|
||||
# 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False)
|
||||
# 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退)
|
||||
# 5. user + template 最近的已完成预览任务(兜底)
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
# 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url)
|
||||
if not rendered_storage_key:
|
||||
# 0a:通过 generated_video_id 查找最终成片视频
|
||||
if body.generated_video_id:
|
||||
logger.info(
|
||||
"[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
)
|
||||
try:
|
||||
gv_repo = get_generated_video_repository(db)
|
||||
gv = gv_repo.get(body.generated_video_id)
|
||||
if gv:
|
||||
file_url = getattr(gv, "file_url", "") or ""
|
||||
if file_url:
|
||||
# 权限校验(双重,任何一层确认归属不符即拒绝):
|
||||
# 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行)
|
||||
gv_owner = (getattr(gv, "user_id", "") or "").strip()
|
||||
if gv_owner and gv_owner != current_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该视频")
|
||||
# 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行:
|
||||
# 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问
|
||||
gv_task_id = getattr(gv, "generation_task_id", "") or ""
|
||||
task0 = None
|
||||
if gv_task_id:
|
||||
try:
|
||||
task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
gv_task_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if task0 is not None:
|
||||
task_owner = (getattr(task0, "created_by_user_id", "") or "").strip()
|
||||
if task_owner and task_owner != current_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该视频")
|
||||
elif not gv_owner:
|
||||
# video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="无权访问该视频")
|
||||
rendered_storage_key = file_url
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
file_url[:80],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF
|
||||
if not rendered_storage_key and body.video_url:
|
||||
if _is_trusted_media_url(body.video_url):
|
||||
logger.info(
|
||||
"[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
body.video_url[:80],
|
||||
)
|
||||
rendered_storage_key = body.video_url
|
||||
else:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
body.video_url[:80],
|
||||
)
|
||||
|
||||
# 步骤 2:通过 plan.config.generation_task_id 查找
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
|
||||
)
|
||||
if generation_task_id:
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
)
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = _repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
rendered_storage_key = _get_task_video_url(db, task.id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
@@ -211,26 +409,23 @@ def generate_cover(
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
"[封面生成] 步骤2查找失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
|
||||
# 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(pt.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id)
|
||||
all_tasks = _repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in all_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False):
|
||||
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
|
||||
"[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
@@ -238,66 +433,74 @@ def generate_cover(
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
|
||||
"[封面生成] 步骤3查找最终成片失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
# 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id)
|
||||
preview_tasks = _repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤4查找预览任务失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 5:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info(
|
||||
"[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
)
|
||||
preview_tasks = _repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
"[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
preview_tasks[0].id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错,
|
||||
# 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
|
||||
# 将 storage_key 转换为可访问 URL;找不到视频时不立即报错,
|
||||
# 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
|
||||
primary_video_url = None
|
||||
if rendered_storage_key:
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取预览视频URL失败: plan_id=%s err=%s", plan_id, e)
|
||||
primary_video_url = None
|
||||
primary_video_url = _resolve_storage_key_to_url(rendered_storage_key)
|
||||
logger.info(
|
||||
"[封面生成] 封面抽帧视频URL: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
@@ -326,20 +529,67 @@ def generate_cover(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
|
||||
# 步骤 A2:通过 generated_video_id 查找其关联任务的 cover_url
|
||||
if not cover_url_from_task and body.generated_video_id:
|
||||
try:
|
||||
gv_repo = get_generated_video_repository(db)
|
||||
gv = gv_repo.get(body.generated_video_id)
|
||||
if gv:
|
||||
gv_task_id = getattr(gv, "generation_task_id", "") or ""
|
||||
if gv_task_id:
|
||||
task_a2 = gen_task_repo.get(gv_task_id)
|
||||
if task_a2 and getattr(task_a2, "cover_url", ""):
|
||||
cover_url_from_task = task_a2.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤A2读取 cover_url 失败: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联任务的 cover_url
|
||||
# 优先最终成片任务(is_preview=False),其次预览任务
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
|
||||
all_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
# 先找最终成片
|
||||
for pt in all_tasks:
|
||||
if (
|
||||
getattr(pt, "status", "") == "completed"
|
||||
and not getattr(pt, "is_preview", False)
|
||||
and getattr(pt, "cover_url", "")
|
||||
):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
|
||||
"[封面生成] 封面(步骤B-final): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
# 再找预览
|
||||
if not cover_url_from_task:
|
||||
for pt in all_tasks:
|
||||
if (
|
||||
getattr(pt, "status", "") == "completed"
|
||||
and getattr(pt, "is_preview", False)
|
||||
and getattr(pt, "cover_url", "")
|
||||
):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 封面(步骤B-preview): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
|
||||
|
||||
@@ -358,6 +358,41 @@ def create_preview_generation_task(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
|
||||
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
|
||||
# 保证各预览版本内容不同
|
||||
if task.source_edit_plan_id:
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
_preview_plan = _plan_svc.clone_plan_for_variant(
|
||||
task.source_edit_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix="预览变体",
|
||||
)
|
||||
task.source_edit_plan_id = _preview_plan.id
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
|
||||
task.id,
|
||||
_preview_plan.id,
|
||||
)
|
||||
except Exception as clone_err:
|
||||
# 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
|
||||
# 标记任务失败并中断,前端可重新发起预览
|
||||
logger.error(
|
||||
"[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
|
||||
task.id,
|
||||
clone_err,
|
||||
exc_info=True,
|
||||
)
|
||||
_mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from clone_err
|
||||
|
||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -116,8 +115,8 @@ def _select_assets_from_library(
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -127,12 +126,6 @@ def _select_assets_from_library(
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
if mode == "random":
|
||||
selected = (
|
||||
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
|
||||
)
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
@@ -292,8 +285,8 @@ def create_generation_task(
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
|
||||
assets = asset_repository.find_by_project(project_id)
|
||||
if assets:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
@@ -425,8 +418,56 @@ def create_generation_task(
|
||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
||||
effective_strategy_id = "one_take"
|
||||
|
||||
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
|
||||
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
|
||||
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
|
||||
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
|
||||
variant_plan_ids: list[str] = []
|
||||
if count > 1 and request.source_edit_plan_id:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
for task_index in range(1, count):
|
||||
variant = None
|
||||
last_err: Exception | None = None
|
||||
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
|
||||
try:
|
||||
variant = _plan_svc.clone_plan_for_variant(
|
||||
request.source_edit_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
)
|
||||
break
|
||||
except Exception as clone_err: # noqa: PERF203
|
||||
last_err = clone_err
|
||||
logger.warning(
|
||||
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
|
||||
_attempt + 1,
|
||||
request.source_edit_plan_id,
|
||||
clone_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant is None:
|
||||
logger.error(
|
||||
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
|
||||
request.source_edit_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from last_err
|
||||
variant_plan_ids.append(variant.id)
|
||||
|
||||
try:
|
||||
for _ in range(count):
|
||||
for task_index in range(count):
|
||||
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
|
||||
# 无源 plan(source_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
|
||||
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
|
||||
effective_plan_id = request.source_edit_plan_id
|
||||
if task_index > 0 and variant_plan_ids:
|
||||
effective_plan_id = variant_plan_ids[task_index - 1]
|
||||
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
@@ -438,7 +479,7 @@ def create_generation_task(
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
source_edit_plan_id=effective_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
@@ -17,7 +17,7 @@ def get_ingest_job(
|
||||
) -> IngestJobResponse:
|
||||
job = ingest_job_repository.get(job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"IngestJob {job_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"IngestJob {job_id} not found")
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
project_id=job.project_id,
|
||||
|
||||
@@ -23,6 +23,14 @@ import re
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_asset_repository, get_db_session
|
||||
from app.services.asset_segment_tracker import (
|
||||
REUSE_RATIO_LIMIT,
|
||||
SEGMENT_EDGE_GAP,
|
||||
get_used_segments,
|
||||
make_reuse_callback,
|
||||
record_used_segments,
|
||||
remove_used_segment,
|
||||
)
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
@@ -430,11 +438,16 @@ def _recommended_time_conflicts(
|
||||
start: float,
|
||||
duration: float,
|
||||
used: list[tuple[float, float]],
|
||||
edge_gap: float = SEGMENT_EDGE_GAP,
|
||||
) -> bool:
|
||||
"""检查推荐起始时间是否与已使用时间段冲突."""
|
||||
"""检查推荐起始时间是否与已使用时间段冲突.
|
||||
|
||||
冲突检测统一加 ``edge_gap`` 秒边缘间隙:已用区间按 [s-gap, e+gap] 扩边后判定,
|
||||
避免推荐片段与已用片段首尾紧贴导致画面观感重复。
|
||||
"""
|
||||
end = start + duration
|
||||
for used_start, used_end in used:
|
||||
if start < used_end and end > used_start:
|
||||
if start < used_end + edge_gap and end > used_start - edge_gap:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -600,49 +613,106 @@ def create_clips_from_assets_editor(
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
|
||||
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
|
||||
used_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
|
||||
# 格式与 _calc_random_start_time 的 used_segments 参数一致
|
||||
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(
|
||||
db, unique_asset_ids
|
||||
)
|
||||
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
|
||||
# 复用片段时长累加到 reused_durations 供 15% 占比控制
|
||||
reused_durations: dict[str, float] = {}
|
||||
# 本条成片中每个素材被分配的片段总时长(复用占比分母)
|
||||
asset_assigned_durations: dict[str, float] = {}
|
||||
# 受控复用回调:区间耗尽时复用最久未用且 use_count<3 的历史区间;
|
||||
# 回调内部预判复用后占比是否超 15%,超限拒绝复用(返回 None)
|
||||
reuse_cb = make_reuse_callback(
|
||||
db,
|
||||
asset_durations,
|
||||
reused_durations,
|
||||
assigned_tracker=asset_assigned_durations,
|
||||
)
|
||||
clips_data: list[dict] = []
|
||||
|
||||
def _reuse_ratio_exceeded(aid: str, extra: float = 0.0) -> bool:
|
||||
"""该素材在本条成片中「已复用片段时长 / 已分配片段总时长」是否已超 15%。
|
||||
|
||||
在为下一片段选素材时调用:本片段尚未分配,复用状态只在分配后的回调里
|
||||
更新,因此直接检查当前占比——一旦已超 15%,该素材不再参与后续分配。
|
||||
assigned=0(首个片段)放行;reused=0(尚未发生复用)时不误拦正常分配。
|
||||
"""
|
||||
assigned = asset_assigned_durations.get(aid, 0.0)
|
||||
if assigned <= 0:
|
||||
return False
|
||||
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 轮询分配素材
|
||||
asset_id = body.asset_ids[i % len(body.asset_ids)]
|
||||
asset_total = asset_durations.get(asset_id, 0.0)
|
||||
|
||||
# 素材时长为 0 或缺失时无法创建有效片段
|
||||
if asset_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长信息缺失或为0,无法创建片段",
|
||||
)
|
||||
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
clip_duration = round(raw_duration, 1)
|
||||
|
||||
# 素材时长不足时缩短 clip duration
|
||||
clip_duration = min(clip_duration, asset_total)
|
||||
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
|
||||
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
|
||||
# 继续轮询下一个素材
|
||||
asset_id = ""
|
||||
clip_duration = 0.0
|
||||
start_time: float | None = None
|
||||
n_assets = len(body.asset_ids)
|
||||
for offset in range(n_assets):
|
||||
candidate = body.asset_ids[(i + offset) % n_assets]
|
||||
candidate_total = asset_durations.get(candidate, 0.0)
|
||||
if candidate_total <= 0:
|
||||
continue
|
||||
candidate_duration = min(round(raw_duration, 1), candidate_total)
|
||||
if candidate_duration <= 0:
|
||||
continue
|
||||
if _reuse_ratio_exceeded(candidate, candidate_duration):
|
||||
logger.info(
|
||||
"from-assets 素材复用占比超 %.0f%% 阈值,跳过分配: asset_id=%s",
|
||||
REUSE_RATIO_LIMIT * 100,
|
||||
candidate,
|
||||
)
|
||||
continue
|
||||
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
|
||||
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
|
||||
# 预判复用后占比超 15% 则拒绝并返回 None)
|
||||
candidate_start = _calc_random_start_time(
|
||||
candidate,
|
||||
candidate_duration,
|
||||
asset_durations,
|
||||
used_segments,
|
||||
on_exhausted=reuse_cb,
|
||||
)
|
||||
if candidate_start is None:
|
||||
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材
|
||||
logger.info(
|
||||
"from-assets 素材无可用可切区间(复用被拒),轮询下一素材: asset_id=%s",
|
||||
candidate,
|
||||
)
|
||||
continue
|
||||
asset_id = candidate
|
||||
clip_duration = candidate_duration
|
||||
start_time = candidate_start
|
||||
break
|
||||
|
||||
if clip_duration <= 0:
|
||||
if not asset_id or start_time is None:
|
||||
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长不足,无法创建有效片段",
|
||||
detail="素材可切区间不足,请补充新素材",
|
||||
)
|
||||
|
||||
# 使用随机起始时间(不调用MediaKit,保证接口快速返回)
|
||||
start_time = _calc_random_start_time(
|
||||
asset_id, clip_duration, asset_durations, used_segments
|
||||
)
|
||||
|
||||
if start_time is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长信息缺失,无法计算起始时间",
|
||||
)
|
||||
|
||||
# 记录已使用时间段
|
||||
# 记录已使用时间段(内存,供本次后续片段避开)
|
||||
used_segments.setdefault(asset_id, []).append(
|
||||
(start_time, start_time + clip_duration)
|
||||
)
|
||||
asset_assigned_durations[asset_id] = (
|
||||
asset_assigned_durations.get(asset_id, 0.0) + clip_duration
|
||||
)
|
||||
# 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional
|
||||
# 处于同一事务,任一步失败整体回滚,不留脏数据);
|
||||
# 复用区间与历史记录高度重叠时 record 内部自动累加 use_count
|
||||
record_used_segments(
|
||||
db, asset_id, start_time, start_time + clip_duration, plan_id
|
||||
)
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
@@ -746,6 +816,10 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
(clip.id, clip.start_time, clip.start_time + clip.duration)
|
||||
)
|
||||
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
|
||||
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
|
||||
historical_segments = get_used_segments(db, unique_asset_ids)
|
||||
|
||||
# 已更新的片段ID(用于排除已移动的旧时间段)
|
||||
updated_clip_ids: set[str] = set()
|
||||
# 已更新的时间段
|
||||
@@ -787,11 +861,23 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if cid != clip.id and cid not in updated_clip_ids
|
||||
]
|
||||
other_segments.extend(updated_segments.get(asset_id, []))
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
|
||||
# 本 plan 片段创建时已写入历史记录
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
|
||||
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
|
||||
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
|
||||
def _norm(segs):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
|
||||
|
||||
# 检查是否与同素材其他片段时间段冲突
|
||||
other_segments = list(
|
||||
_norm(other_segments) | _norm(historical_segments.get(asset_id, []))
|
||||
)
|
||||
|
||||
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
|
||||
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
|
||||
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
|
||||
logger.info(
|
||||
"后台任务: 推荐时间冲突,跳过: asset_id=%s recommended=%.2f",
|
||||
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
@@ -799,7 +885,32 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
|
||||
# 逐个更新并捕获异常(单点失败不影响其他片段)
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
|
||||
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
|
||||
# 写新区间,最后统一 commit;任一步失败整体 rollback,
|
||||
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
try:
|
||||
if remove_used_segment(
|
||||
db, asset_id, old_start, old_end, plan_id=plan_id
|
||||
):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
|
||||
+195
-59
@@ -3,17 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_user_repository,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
from app.schemas.tts import (
|
||||
ListTTSJobResponse,
|
||||
@@ -27,12 +31,12 @@ from app.schemas.tts import (
|
||||
TTSSynthesizeResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
|
||||
SQLAlchemyTTSJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
from packages.application.tts_job.use_cases import (
|
||||
@@ -44,13 +48,12 @@ from packages.application.tts_job.use_cases import (
|
||||
TTSJobNotFoundError,
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
|
||||
from packages.domain.voice_presets import list_voices
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,27 +137,47 @@ def synthesize(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
|
||||
if request.voice_clone_profile_id:
|
||||
profile = voice_clone_repo.get(request.voice_clone_profile_id)
|
||||
if profile is None:
|
||||
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
|
||||
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
|
||||
actual_voice_id = request.voice_id
|
||||
voice_clone_profile_id = request.voice_clone_profile_id
|
||||
resolved_profile = None
|
||||
if actual_voice_id:
|
||||
resolved_profile = voice_clone_repo.get(actual_voice_id)
|
||||
if resolved_profile is not None:
|
||||
voice_clone_profile_id = actual_voice_id
|
||||
|
||||
# 显式传了 voice_clone_profile_id(且与 voice_id 不同)时再查一次归属
|
||||
if voice_clone_profile_id and (resolved_profile is None or resolved_profile.id != voice_clone_profile_id):
|
||||
resolved_profile = voice_clone_repo.get(voice_clone_profile_id)
|
||||
if resolved_profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Voice clone profile not found",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
|
||||
if resolved_profile is not None:
|
||||
if resolved_profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to voice clone profile",
|
||||
detail="无权访问该音色",
|
||||
)
|
||||
if not resolved_profile.voice_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="音色克隆尚未完成,请稍后再试",
|
||||
)
|
||||
# 命中克隆音色:无论 voice_id 直接传 profile UUID 还是显式传 voice_clone_profile_id,
|
||||
# job.voice_id 统一存解析后的 CosyVoice voice_id
|
||||
actual_voice_id = resolved_profile.voice_id
|
||||
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
input_text=request.text,
|
||||
voice_id=request.voice_id,
|
||||
voice_id=actual_voice_id,
|
||||
voice_model=request.voice_model,
|
||||
voice_clone_profile_id=request.voice_clone_profile_id,
|
||||
voice_clone_profile_id=voice_clone_profile_id,
|
||||
metadata=request.metadata_,
|
||||
)
|
||||
|
||||
@@ -284,6 +307,62 @@ def delete_tts_job(
|
||||
return
|
||||
|
||||
|
||||
def _find_or_create_voice_library(
|
||||
*,
|
||||
user_id: str,
|
||||
project_repository: ProjectRepository,
|
||||
asset_library_repository: Any, # port Protocol 声明为 async,SQLAlchemy 实现为同步,与 upload/asset_libraries 路由惯例一致用 Any
|
||||
) -> AssetLibrary:
|
||||
"""在用户可访问的项目中找到(或自动创建)voice 素材库。
|
||||
|
||||
与前端配音素材页逻辑一致:素材库挂在项目下,配音素材读取
|
||||
getAssetsByKind("voice") → 用户所有可访问项目中的 voice 库。
|
||||
优先使用已有 voice 库;没有则在第一个可访问项目中自动创建。
|
||||
"""
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
if not projects:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="没有可用的项目,请先创建项目后再保存配音素材",
|
||||
)
|
||||
|
||||
for project in projects:
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
return lib
|
||||
|
||||
# 所有项目都没有 voice 库 → 在第一个可访问项目中自动创建默认配音素材库。
|
||||
# asset_libraries 有 (project_id, kind) 唯一索引兜底并发:若两个请求同时创建,
|
||||
# 落败方捕获 IntegrityError 回滚后重新查询,返回抢先创建成功的库。
|
||||
project = projects[0]
|
||||
library = AssetLibrary.create(
|
||||
project_id=project.id,
|
||||
name="配音素材库",
|
||||
kind=AssetLibraryKind.VOICE,
|
||||
)
|
||||
try:
|
||||
return asset_library_repository.create(library)
|
||||
except IntegrityError:
|
||||
# 并发下另一个请求已抢先创建:回滚当前事务(立即 commit 模式下 session 已
|
||||
# 自动回滚,rollback 为幂等 no-op;UoW/flush 模式下必须显式回滚才能继续查询),
|
||||
# 再重查返回抢先创建成功的库。
|
||||
session = getattr(asset_library_repository, "session", None)
|
||||
if session is not None:
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception:
|
||||
logger.warning("IntegrityError 后回滚 session 失败(可能已关闭)", exc_info=True)
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
return lib
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="配音素材库创建失败,请重试",
|
||||
) from None # IntegrityError 已处理,不保留异常链
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jobs/{job_id}/save-to-library",
|
||||
response_model=SaveToLibraryResponse,
|
||||
@@ -294,13 +373,17 @@ def save_tts_job_to_library(
|
||||
request: SaveToLibraryRequest = SaveToLibraryRequest(),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
asset_repository: AssetRepository = Depends(get_asset_repository),
|
||||
asset_library_repository: AssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
project_repository: ProjectRepository = Depends(get_project_repository),
|
||||
storage_service: SharedStorageService = Depends(get_storage_service),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> SaveToLibraryResponse:
|
||||
"""将已完成的 TTS 合成结果保存到配音库。
|
||||
"""将已完成的 TTS 合成结果保存到配音素材库(assets 表新素材体系)。
|
||||
|
||||
自动携带音色名、时长、语速等元信息。
|
||||
流程:把 TTS 输出音频转存到用户素材 OSS 路径 → 创建 file_type=audio、
|
||||
status=ready 的 asset(挂用户 voice 素材库)→ 返回前端可用结构。
|
||||
配额策略与素材上传一致(上传/ingest 链路无额外配额拦截)。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
@@ -318,64 +401,117 @@ def save_tts_job_to_library(
|
||||
detail="TTS job is not completed yet",
|
||||
)
|
||||
|
||||
# 构建配音素材名称
|
||||
if not job.output_audio_url and not job.output_audio_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="TTS job 缺少输出音频,无法保存",
|
||||
)
|
||||
|
||||
# 素材名称
|
||||
name = request.name or f"TTS-{job.id[:8]}"
|
||||
|
||||
# 构建元信息
|
||||
metadata_ = {
|
||||
# 找到(或自动创建)用户 voice 素材库
|
||||
library = _find_or_create_voice_library(
|
||||
user_id=user_id,
|
||||
project_repository=project_repository,
|
||||
asset_library_repository=asset_library_repository,
|
||||
)
|
||||
|
||||
# 转存音频到素材 OSS 路径(tts-outputs/ 下的产物归 TTS 任务所有,
|
||||
# 素材独立持有副本,删除 TTS 任务不影响配音库素材)
|
||||
audio_format = (job.format or "mp3").strip() or "mp3"
|
||||
content_type_map = {
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"pcm": "audio/pcm",
|
||||
"opus": "audio/opus",
|
||||
}
|
||||
content_type = content_type_map.get(audio_format, "audio/mpeg")
|
||||
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
|
||||
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
# 优先用 OSS storage_key(走 oss2 SDK,私有 bucket 也可下载);
|
||||
# 兜底用 output_audio_url(旧任务可能没有 key)。
|
||||
# download_asset 自动识别输入:http(s):// 开头走 HTTP 下载,否则按 OSS key 走 SDK。
|
||||
download_source = job.output_audio_key or job.output_audio_url
|
||||
downloaded = storage_service.download_asset(download_source, tmp_path)
|
||||
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="TTS 音频下载失败,无法保存到配音库",
|
||||
)
|
||||
file_size = tmp_path.stat().st_size
|
||||
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="TTS 音频转存失败,无法保存到配音库",
|
||||
) from e
|
||||
finally:
|
||||
if tmp_path and tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 构建素材元信息
|
||||
metadata_: dict[str, object] = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
"voice_id": job.voice_id,
|
||||
"voice_name": job.voice_model or "",
|
||||
}
|
||||
if job.metadata:
|
||||
# 保留原始 job 的有用元信息
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata_[key] = job.metadata[key]
|
||||
|
||||
# 获取用户套餐(用于配额检查)
|
||||
user = user_repository.find_by_id(user_id)
|
||||
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
|
||||
|
||||
# 构建命令并执行
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
asset = Asset.create(
|
||||
project_id=library.project_id,
|
||||
library_id=library.id,
|
||||
name=name,
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id or "",
|
||||
tags=[],
|
||||
metadata_=metadata_,
|
||||
storage_key=storage_key,
|
||||
mime_type=content_type,
|
||||
metadata=metadata_,
|
||||
file_size=file_size,
|
||||
duration=job.duration or None,
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name or "free")
|
||||
except QuotaExceededError as exc:
|
||||
asset = asset_repository.create(asset)
|
||||
except Exception as e:
|
||||
# DB 写入失败:清理已上传到 OSS 的素材文件,避免产生无法索引的孤儿文件
|
||||
logger.error("素材记录创建失败,清理 OSS 文件: %s, error=%s", storage_key, e, exc_info=True)
|
||||
try:
|
||||
storage_service.delete_file(storage_key)
|
||||
except Exception:
|
||||
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="素材保存失败,请重试",
|
||||
) from e
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
name=item.name,
|
||||
audio_url=sign_url(item.audio_url) if item.audio_url else "",
|
||||
duration=item.duration,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
status=item.status,
|
||||
id=asset.id,
|
||||
name=asset.name,
|
||||
audio_url=sign_url(storage_key),
|
||||
duration=asset.duration or 0.0,
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
status="completed",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/preview", response_model=TTSPreviewResponse)
|
||||
def preview_tts(
|
||||
request: TTSPreviewRequest,
|
||||
|
||||
@@ -7,7 +7,13 @@ from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_cosyvoice_service,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
ListVoiceCloneResponse,
|
||||
@@ -32,6 +38,9 @@ from packages.application.voice_clone.use_cases import (
|
||||
from packages.application.voice_clone.workflow import (
|
||||
VoiceCloneWorkflowService,
|
||||
)
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,23 +92,68 @@ def create_voice_clone(
|
||||
request: CreateVoiceCloneRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
|
||||
asset_repository: AssetRepository = Depends(get_asset_repository),
|
||||
project_repository: ProjectRepository = Depends(get_project_repository),
|
||||
storage_service: SharedStorageService = Depends(get_storage_service),
|
||||
) -> VoiceCloneProfileResponse:
|
||||
"""创建音色克隆任务。
|
||||
|
||||
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
|
||||
如果有 source_audio_url,状态会变为 processing;否则保持 pending。
|
||||
参考音频两种来源(二选一):
|
||||
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
|
||||
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
|
||||
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
|
||||
如果有参考音频,状态会变为 processing;否则保持 pending。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
source_audio_url = request.source_audio_url
|
||||
clone_metadata = dict(request.metadata_ or {})
|
||||
|
||||
if request.asset_id:
|
||||
if source_audio_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="asset_id 与 source_audio_url 只能传一个",
|
||||
)
|
||||
asset = asset_repository.find_by_id(request.asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="素材不存在",
|
||||
)
|
||||
# 归属校验:素材挂在项目素材库下,用户必须能访问该项目
|
||||
project = project_repository.find_by_id(asset.project_id)
|
||||
if project is None or not project.can_access(user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权使用该素材",
|
||||
)
|
||||
# 类型校验:仅支持音频素材
|
||||
if asset.file_type != "audio":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="仅支持音频素材进行音色克隆",
|
||||
)
|
||||
if not asset.storage_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该素材缺少音频文件,无法用于克隆",
|
||||
)
|
||||
# 用 OSS storage_key 生成服务端预签名 URL(7 天有效,覆盖克隆重试周期)
|
||||
source_audio_url = storage_service.get_download_url(asset.storage_key, expires_seconds=7 * 24 * 3600)
|
||||
clone_metadata["source_asset_id"] = asset.id
|
||||
|
||||
profile = workflow.start_clone(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
source_audio_url=request.source_audio_url,
|
||||
source_audio_url=source_audio_url,
|
||||
voice_model=request.voice_model,
|
||||
language=request.language,
|
||||
gender=request.gender,
|
||||
max_retries=request.max_retries,
|
||||
metadata=request.metadata_,
|
||||
metadata=clone_metadata,
|
||||
)
|
||||
|
||||
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
||||
|
||||
@@ -53,6 +53,14 @@ class AssetResponse(BaseModel):
|
||||
created_at: str
|
||||
uploaded_by_user_id: str
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
# 片段级余量信息(仅视频素材返回,非视频/无时长记录为 None,前端按可用处理)
|
||||
used_duration: float | None = Field(default=None, description="已使用片段时长(秒,历史区间合并去重后)")
|
||||
available_duration: float | None = Field(default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长")
|
||||
used_ratio: float | None = Field(default=None, description="已用时长占比(0~1)")
|
||||
usable: bool = Field(
|
||||
default=True,
|
||||
description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" "(use_count)均达上限时为 false",
|
||||
)
|
||||
|
||||
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
@@ -45,11 +45,9 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
# ── 素材库自动匹配 ──
|
||||
asset_select_mode: str = Field(
|
||||
default="all",
|
||||
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
|
||||
)
|
||||
asset_select_count: int = Field(
|
||||
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
||||
description="素材选取模式:all=全部ready视频, smart=智能匹配(按质量/时长评分)",
|
||||
)
|
||||
asset_select_count: int = Field(default=0, ge=0, le=100, description="选取数量,0表示全部(仅 smart 模式有效)")
|
||||
# ── 自动重试 ──
|
||||
auto_retry_enabled: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -13,7 +13,8 @@ class CreateVoiceCloneRequest(BaseModel):
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="音色名称")
|
||||
description: str = Field("", description="音色描述")
|
||||
source_audio_url: str = Field("", description="参考音频 URL")
|
||||
source_audio_url: str = Field("", description="参考音频 URL(与 asset_id 二选一)")
|
||||
asset_id: str = Field("", description="参考音频素材 ID(配音素材库中的音频 asset,与 source_audio_url 二选一)")
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
gender: str = Field("unknown", description="性别")
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""素材片段级使用记录追踪与受控复用.
|
||||
|
||||
在素材 metadata(assets.classification_result JSON)中持久化已使用的片段时间区间,
|
||||
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
|
||||
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
|
||||
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
|
||||
成片的画面重复率控制在阈值内。
|
||||
|
||||
metadata 中的记录字段 ``used_time_ranges``::
|
||||
|
||||
"used_time_ranges": [
|
||||
{
|
||||
"start": 12.5, "end": 20.3,
|
||||
"plan_id": "plan-xxx",
|
||||
"created_at": "2026-08-29T12:00:00+00:00",
|
||||
"use_count": 1, # 该区间累计被使用次数(复用一次 +1)
|
||||
"last_used_at": "2026-08-29T12:00:00+00:00" # 最近一次使用时间
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
注意:本模块所有函数都不自行 commit,由调用方控制事务边界
|
||||
(from-assets 与 replace_all_clips_transactional 同事务;异步任务各自 commit)。
|
||||
历史记录永不自动清空(自动轮回重置已下线,reset_used_segments 仅保留给运维/测试)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USED_RANGES_KEY = "used_time_ranges"
|
||||
|
||||
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
|
||||
MAX_RANGE_USE_COUNT = 3
|
||||
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
|
||||
|
||||
REUSE_RATIO_LIMIT = 0.15
|
||||
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
|
||||
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
|
||||
|
||||
SEGMENT_EDGE_GAP = 0.3
|
||||
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
|
||||
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
|
||||
|
||||
# 判定"新片段与历史区间为同一次使用(复用)"的重叠率阈值:
|
||||
# 重叠时长 / 新区间时长超过该比例视为复用该历史区间(累加 use_count)而非新增记录。
|
||||
_REUSE_OVERLAP_RATIO = 0.6
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _read_meta(model) -> dict:
|
||||
"""读取素材 metadata dict。
|
||||
|
||||
兼容两种对象:
|
||||
- ORM ``AssetModel``:metadata 以 JSON 字符串存在 ``classification_result`` 列;
|
||||
- 领域实体 ``Asset``(路由层 repository 返回):metadata 直接是 dict 属性
|
||||
(repository 与 classification_result 互转,见 asset_repository.py)。
|
||||
"""
|
||||
# 领域实体:metadata 已是 dict
|
||||
meta = getattr(model, "metadata", None)
|
||||
if isinstance(meta, dict):
|
||||
return meta
|
||||
raw = getattr(model, "classification_result", None)
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_model(db: Session, asset_id: str, for_update: bool = False) -> AssetModel | None:
|
||||
query = db.query(AssetModel).filter(AssetModel.id == asset_id)
|
||||
if for_update:
|
||||
# 行级锁(PostgreSQL SELECT ... FOR UPDATE):序列化同一素材的
|
||||
# classification_result 读-改-写,避免并发事务丢失使用记录。
|
||||
# SQLite 不支持时 SQLAlchemy 会忽略该子句(no-op)。
|
||||
query = query.with_for_update()
|
||||
return query.first()
|
||||
|
||||
|
||||
def get_used_segments(db: Session, asset_ids: list[str]) -> dict[str, list[tuple[float, float]]]:
|
||||
"""聚合多个素材的历史已用片段区间。
|
||||
|
||||
Returns:
|
||||
``{asset_id: [(start, end), ...]}`` 格式,与 ``_calc_random_start_time`` 的
|
||||
``used_segments`` 参数格式一致,可直接传入。
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
result: dict[str, list[tuple[float, float]]] = {}
|
||||
models = db.query(AssetModel).filter(AssetModel.id.in_(list(set(asset_ids)))).all()
|
||||
for model in models:
|
||||
meta = _read_meta(model)
|
||||
ranges = meta.get(USED_RANGES_KEY) or []
|
||||
segments: list[tuple[float, float]] = []
|
||||
for r in ranges:
|
||||
try:
|
||||
segments.append((float(r["start"]), float(r["end"])))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if segments:
|
||||
result[model.id] = segments
|
||||
return result
|
||||
|
||||
|
||||
def record_used_segments(
|
||||
db: Session,
|
||||
asset_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
plan_id: str,
|
||||
) -> None:
|
||||
"""记录一次片段使用(不 commit).
|
||||
|
||||
若新区间与某条历史区间高度重叠(复用场景,如受控复用回调返回的区间、
|
||||
MediaKit 挪到历史区间),则累加该记录的 ``use_count`` 并刷新 ``last_used_at``,
|
||||
不新增记录;否则追加一条新记录(use_count=1)。
|
||||
"""
|
||||
# 行级锁读取:与并发生成任务互斥,保证区间记录读-改-写一致
|
||||
model = _get_model(db, asset_id, for_update=True)
|
||||
if model is None:
|
||||
logger.warning("[片段追踪] 素材不存在,跳过记录: asset_id=%s", asset_id)
|
||||
return
|
||||
meta = _read_meta(model)
|
||||
ranges = list(meta.get(USED_RANGES_KEY) or [])
|
||||
|
||||
new_start = round(float(start), 3)
|
||||
new_end = round(float(end), 3)
|
||||
new_dur = max(new_end - new_start, 1e-6)
|
||||
now = _now_iso()
|
||||
|
||||
for r in ranges:
|
||||
try:
|
||||
rs, re_ = float(r["start"]), float(r["end"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
overlap = max(0.0, min(new_end, re_) - max(new_start, rs))
|
||||
if overlap / new_dur >= _REUSE_OVERLAP_RATIO:
|
||||
# 复用同一条历史区间:累加次数、刷新时间
|
||||
r["use_count"] = int(r.get("use_count", 1)) + 1
|
||||
r["last_used_at"] = now
|
||||
r["plan_id"] = plan_id
|
||||
meta[USED_RANGES_KEY] = ranges
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
return
|
||||
|
||||
ranges.append(
|
||||
{
|
||||
"start": new_start,
|
||||
"end": new_end,
|
||||
"plan_id": plan_id,
|
||||
"created_at": now,
|
||||
"use_count": 1,
|
||||
"last_used_at": now,
|
||||
}
|
||||
)
|
||||
meta[USED_RANGES_KEY] = ranges
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def remove_used_segment(
|
||||
db: Session,
|
||||
asset_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
plan_id: str | None = None,
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
"""删除素材 metadata 中匹配的一条使用记录(不 commit).
|
||||
|
||||
匹配规则:start/end 与记录值相差不超过 tolerance 秒;plan_id 非空时,
|
||||
记录有 plan_id 则需相等,记录缺 plan_id(本功能上线前的旧数据)时按时间匹配。
|
||||
Returns:
|
||||
是否找到并删除了记录。
|
||||
"""
|
||||
model = _get_model(db, asset_id)
|
||||
if model is None:
|
||||
return False
|
||||
meta = _read_meta(model)
|
||||
ranges = list(meta.get(USED_RANGES_KEY) or [])
|
||||
remaining: list[dict] = []
|
||||
removed = False
|
||||
for r in ranges:
|
||||
try:
|
||||
match = (
|
||||
abs(float(r["start"]) - float(start)) <= tolerance and abs(float(r["end"]) - float(end)) <= tolerance
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
remaining.append(r)
|
||||
continue
|
||||
# plan_id 校验:传入 plan_id 时,记录有 plan_id 则必须相等;
|
||||
# 记录本身缺 plan_id(旧数据)时退化为按时间匹配,避免旧区间永远删不掉
|
||||
if plan_id is not None and r.get("plan_id") is not None and r.get("plan_id") != plan_id:
|
||||
match = False
|
||||
if match and not removed:
|
||||
removed = True
|
||||
continue
|
||||
remaining.append(r)
|
||||
if removed:
|
||||
meta[USED_RANGES_KEY] = remaining
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
return removed
|
||||
|
||||
|
||||
def reset_used_segments(db: Session, asset_id: str) -> None:
|
||||
"""清空单个素材的历史片段使用记录(不 commit).
|
||||
|
||||
仅供运维/测试使用;正常生成流程中历史记录永不自动清空(受控复用取代自动轮回)。
|
||||
"""
|
||||
model = _get_model(db, asset_id)
|
||||
if model is None:
|
||||
return
|
||||
meta = _read_meta(model)
|
||||
if meta.get(USED_RANGES_KEY):
|
||||
meta[USED_RANGES_KEY] = []
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
|
||||
|
||||
|
||||
# ── 素材余量/可用性计算(Task H:素材库角标 + smart-match 过滤)──────────────
|
||||
|
||||
# 判定「是否还有空闲可切区间」时使用的最小片段时长(秒):空闲段长于此值才视为可切
|
||||
_MIN_FREE_CLIP_DURATION = 3.0
|
||||
|
||||
|
||||
def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""合并重叠/相接的时间区间,返回升序不重叠区间列表。"""
|
||||
if not intervals:
|
||||
return []
|
||||
ordered = sorted((float(a), float(b)) for a, b in intervals if b > a)
|
||||
merged: list[tuple[float, float]] = [ordered[0]]
|
||||
for start, end in ordered[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if start <= last_end:
|
||||
merged[-1] = (last_start, max(last_end, end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _has_free_gap(used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION) -> bool:
|
||||
"""素材 [0, total] 中是否存在长度 ≥ min_free 的空闲段(考虑边缘间隙)。"""
|
||||
if total <= 0:
|
||||
return False
|
||||
# 历史区间按边缘间隙扩边后判定空闲(与选片冲突检测同一口径)
|
||||
expanded = [(max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) for s, e in used]
|
||||
merged = _merge_intervals(expanded)
|
||||
cursor = 0.0
|
||||
for start, end in merged:
|
||||
if start - cursor >= min_free:
|
||||
return True
|
||||
cursor = max(cursor, end)
|
||||
return total - cursor >= min_free
|
||||
|
||||
|
||||
def compute_asset_availability(
|
||||
model: "AssetModel | None",
|
||||
min_free_clip_duration: float = _MIN_FREE_CLIP_DURATION,
|
||||
) -> dict | None:
|
||||
"""计算单个素材的余量与可用性(纯函数,不读写 DB)。
|
||||
|
||||
Returns:
|
||||
视频素材返回 ``{"used_duration", "available_duration", "used_ratio", "usable"}``;
|
||||
非视频 / 无 model / 无时长信息返回 None(调用方按可用处理,零影响)。
|
||||
|
||||
usable=False 条件(与受控复用机制一致):
|
||||
零重复可切区间已耗尽(不存在 ≥ min_free 的空闲段)且
|
||||
所有历史区间 use_count 均达 MAX_RANGE_USE_COUNT 上限(无区间可复用)。
|
||||
"""
|
||||
if model is None:
|
||||
return None
|
||||
file_type = getattr(model, "file_type", None) or getattr(model, "mime_type", "") or ""
|
||||
if file_type != "video" and not str(file_type).startswith("video/"):
|
||||
return None
|
||||
total = float(getattr(model, "duration", 0.0) or 0.0)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
meta = _read_meta(model)
|
||||
raw_ranges = meta.get(USED_RANGES_KEY) or []
|
||||
|
||||
intervals: list[tuple[float, float]] = []
|
||||
use_counts: list[int] = []
|
||||
for r in raw_ranges:
|
||||
try:
|
||||
start = float(r["start"])
|
||||
end = float(r["end"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if end <= start:
|
||||
continue
|
||||
intervals.append((start, end))
|
||||
try:
|
||||
use_counts.append(int(r.get("use_count", 1)))
|
||||
except (TypeError, ValueError):
|
||||
use_counts.append(1)
|
||||
|
||||
merged = _merge_intervals(intervals)
|
||||
used_duration = round(sum(e - s for s, e in merged), 3)
|
||||
used_duration = min(used_duration, total)
|
||||
available_duration = round(max(total - used_duration, 0.0), 3)
|
||||
used_ratio = round(min(used_duration / total, 1.0), 4)
|
||||
|
||||
has_free = _has_free_gap(intervals, total, min_free_clip_duration)
|
||||
if has_free:
|
||||
usable = True
|
||||
else:
|
||||
# 空闲段耗尽:仅当存在历史区间且全部达复用上限时才判定不可用;
|
||||
# 无历史区间(理论上不会走到,因为 has_free=True)按可用处理
|
||||
if not use_counts:
|
||||
usable = True
|
||||
else:
|
||||
usable = any(uc < MAX_RANGE_USE_COUNT for uc in use_counts)
|
||||
|
||||
return {
|
||||
"used_duration": used_duration,
|
||||
"available_duration": available_duration,
|
||||
"used_ratio": used_ratio,
|
||||
"usable": usable,
|
||||
}
|
||||
|
||||
|
||||
def find_reusable_range(
|
||||
db: Session,
|
||||
asset_id: str,
|
||||
clip_duration: float,
|
||||
asset_total: float,
|
||||
*,
|
||||
max_use_count: int = MAX_RANGE_USE_COUNT,
|
||||
) -> tuple[float, float] | None:
|
||||
"""受控复用:在素材历史区间中选一条可复用区间返回 (start, end)。
|
||||
|
||||
选择规则:
|
||||
1. 仅选 ``use_count < max_use_count`` 的历史区间;
|
||||
2. 优先返回能完整容纳当前 clip_duration(起点后不越素材边界)的最久未用区间;
|
||||
3. 没有能容纳的,则返回 last_used_at 最老(或缺失 last_used_at 的旧数据优先)
|
||||
且 use_count 最低的区间起点(可能与其他历史区间重叠,属降级复用);
|
||||
4. 无任何可复用区间(记录为空或全部达上限)返回 None。
|
||||
|
||||
本函数只读不写;复用次数的累加由后续 record_used_segments 完成。
|
||||
"""
|
||||
model = _get_model(db, asset_id)
|
||||
if model is None:
|
||||
return None
|
||||
meta = _read_meta(model)
|
||||
ranges = [r for r in (meta.get(USED_RANGES_KEY) or []) if int(r.get("use_count", 1)) < max_use_count]
|
||||
if not ranges:
|
||||
return None
|
||||
|
||||
def _last_used(r: dict) -> str:
|
||||
return str(r.get("last_used_at") or r.get("created_at") or "")
|
||||
|
||||
max_start = max(0.0, asset_total - clip_duration)
|
||||
# 2. 能完整容纳当前片段的候选:按 last_used_at 升序(最久未用优先)
|
||||
fit = sorted(
|
||||
[r for r in ranges if float(r["start"]) <= max_start + 1e-6],
|
||||
key=_last_used,
|
||||
)
|
||||
if fit:
|
||||
start = min(float(fit[0]["start"]), max_start)
|
||||
return (start, start + clip_duration)
|
||||
|
||||
# 3. 降级:最久未用 + use_count 最低的区间起点
|
||||
fallback = sorted(ranges, key=lambda r: (_last_used(r), int(r.get("use_count", 1))))[0]
|
||||
start = min(float(fallback["start"]), max_start)
|
||||
return (start, start + clip_duration)
|
||||
|
||||
|
||||
def make_reuse_callback(
|
||||
db: Session,
|
||||
asset_durations: dict[str, float],
|
||||
reused_tracker: dict[str, float] | None = None,
|
||||
assigned_tracker: dict[str, float] | None = None,
|
||||
ratio_limit: float = REUSE_RATIO_LIMIT,
|
||||
) -> Callable[[str, float], tuple[float, float] | None]:
|
||||
"""构造给 ``_calc_random_start_time`` 用的受控复用回调.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session
|
||||
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
|
||||
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
|
||||
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
|
||||
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
|
||||
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
|
||||
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
|
||||
(保证成片复用占比不超阈值)。
|
||||
ratio_limit: 单条成片复用时长占比上限,默认 15%。
|
||||
|
||||
Returns:
|
||||
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
|
||||
回调内吞掉 DB 异常返回 None,不影响主生成流程。
|
||||
"""
|
||||
|
||||
def _reuse(asset_id: str, clip_duration: float) -> tuple[float, float] | None:
|
||||
try:
|
||||
total = float(asset_durations.get(asset_id, 0.0) or 0.0)
|
||||
if total <= 0:
|
||||
return None
|
||||
# 占比闸门:预判复用本片段后是否超限(仅当调用方提供了 assigned tracker)
|
||||
if assigned_tracker is not None:
|
||||
assigned = float(assigned_tracker.get(asset_id, 0.0) or 0.0)
|
||||
reused_amt = float((reused_tracker or {}).get(asset_id, 0.0) or 0.0)
|
||||
if assigned > 0 and (reused_amt + clip_duration) / (assigned + clip_duration) > ratio_limit:
|
||||
logger.info(
|
||||
"[片段追踪] 复用占比预判超 %.0f%% 阈值,拒绝复用: asset_id=%s "
|
||||
"reused=%.1f assigned=%.1f clip=%.1f",
|
||||
ratio_limit * 100,
|
||||
asset_id,
|
||||
reused_amt,
|
||||
assigned,
|
||||
clip_duration,
|
||||
)
|
||||
return None
|
||||
result = find_reusable_range(db, asset_id, clip_duration, total)
|
||||
except Exception:
|
||||
logger.warning("[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True)
|
||||
return None
|
||||
if result is not None and reused_tracker is not None:
|
||||
reused_tracker[asset_id] = reused_tracker.get(asset_id, 0.0) + clip_duration
|
||||
return result
|
||||
|
||||
return _reuse
|
||||
@@ -9,6 +9,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.asset_segment_tracker import (
|
||||
REUSE_RATIO_LIMIT,
|
||||
get_used_segments,
|
||||
make_reuse_callback,
|
||||
record_used_segments,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
@@ -453,6 +459,116 @@ class EditPlanService:
|
||||
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
|
||||
raise
|
||||
|
||||
def clone_plan_for_variant(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
name_suffix: str = "变体",
|
||||
reuse_tracker: Optional[dict] = None,
|
||||
) -> EditPlan:
|
||||
"""为批量/多预览场景克隆一份独立 plan,片段起点全部重算(受控随机/复用)。
|
||||
|
||||
复制源 plan 的模板归属、config 与片段结构(asset_id / duration / clip_type /
|
||||
order 不变),每个片段重新调用 ``_calc_random_start_time``:读取素材持久化的
|
||||
历史已用区间避让,耗尽时受控复用(use_count<3、最久未用),从而保证 N 条
|
||||
成片片段区间互不相同,且复用占比受控。
|
||||
|
||||
- 不替换/不修改源 plan,源 plan 保留用户手动编辑结果。
|
||||
- 片段区间记录(record_used_segments)随新片段写入素材 metadata,与新 plan
|
||||
同事务;复用历史区间时由 record 自动累加 use_count。
|
||||
- 克隆的 clips 复用区间累计时长写入 reuse_tracker(可选),供调用方统计占比。
|
||||
|
||||
Raises:
|
||||
ValueError: 源 plan 不存在或无可用片段。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
if not batch:
|
||||
break
|
||||
clips.extend(batch)
|
||||
if len(batch) < page:
|
||||
break
|
||||
skip += page
|
||||
if not clips:
|
||||
raise ValueError(f"源 plan 无片段,无法克隆变体: {source_plan_id}")
|
||||
|
||||
# 创建新 plan(复制模板归属与 config)
|
||||
new_plan = self.create_plan(
|
||||
template_id=source.template_id,
|
||||
name=f"{source.name or '剪辑计划'} · {name_suffix}",
|
||||
config=dict(source.config or {}),
|
||||
total_duration=source.total_duration,
|
||||
project_id=source.project_id or "",
|
||||
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
|
||||
)
|
||||
|
||||
# 素材时长映射(O(N) 单查)
|
||||
asset_ids = list({c.asset_id for c in clips if c.asset_id})
|
||||
db = self._clip_repo.session
|
||||
durations: dict[str, float] = {}
|
||||
if asset_ids:
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all():
|
||||
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
|
||||
used_segments = get_used_segments(db, asset_ids)
|
||||
reused: dict[str, float] = reuse_tracker if reuse_tracker is not None else {}
|
||||
asset_assigned: dict[str, float] = {}
|
||||
# 回调内部预判复用后占比超 15% 则拒绝复用(calc 返回 None → 保留原起点)
|
||||
reuse_cb = make_reuse_callback(db, durations, reused, assigned_tracker=asset_assigned)
|
||||
|
||||
clips_data: list[dict] = []
|
||||
for i, c in enumerate(clips):
|
||||
aid = c.asset_id
|
||||
dur = float(c.duration or 0.0)
|
||||
total = durations.get(aid, 0.0)
|
||||
if aid and total > 0 and dur > 0:
|
||||
# 复用占比闸门:本片段尚未分配,检查当前已复用占比
|
||||
# reused / assigned 是否超 15%,超则该素材不再分配(保留原起点);
|
||||
# assigned=0(首个片段)放行,reused=0 时不误拦正常分配
|
||||
assigned = asset_assigned.get(aid, 0.0)
|
||||
eff_dur = min(dur, total)
|
||||
reused_amt = reused.get(aid, 0.0)
|
||||
ratio_blocked = assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT
|
||||
start = None
|
||||
if not ratio_blocked:
|
||||
start = _calc_random_start_time(aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb)
|
||||
if start is None:
|
||||
start = float(c.start_time or 0.0)
|
||||
asset_assigned[aid] = assigned + eff_dur
|
||||
used_segments.setdefault(aid, []).append((start, start + eff_dur))
|
||||
record_used_segments(db, aid, start, start + eff_dur, new_plan.id)
|
||||
else:
|
||||
start = float(c.start_time or 0.0)
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": aid,
|
||||
"start_time": start,
|
||||
"duration": dur,
|
||||
"clip_type": c.clip_type,
|
||||
}
|
||||
)
|
||||
|
||||
# 事务性写入新 plan 的片段(内部统一 commit/rollback)
|
||||
self.replace_all_clips_transactional(new_plan.id, clips_data)
|
||||
logger.info(
|
||||
"克隆变体 plan: source=%s new=%s clips=%d",
|
||||
source_plan_id,
|
||||
new_plan.id,
|
||||
len(clips_data),
|
||||
)
|
||||
return new_plan
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
|
||||
@@ -52,7 +52,7 @@ type AssetListResponse = {
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
|
||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
||||
test("walks through 6-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
@@ -214,18 +214,14 @@ test.describe("Core generation flow", () => {
|
||||
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: preview — 前端实时预览架构改造,无需后端生成预览
|
||||
await expect(page.getByRole("heading", { name: /预览设置/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 7: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
// Step 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务
|
||||
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位,
|
||||
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning
|
||||
await page
|
||||
.getByText("准备预览素材")
|
||||
.waitFor({ state: "detached", timeout: 30_000 })
|
||||
.catch(() => {})
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 前端直接创建生成任务:POST /generation/tasks
|
||||
@@ -238,10 +234,10 @@ test.describe("Core generation flow", () => {
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// Click generate button
|
||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
|
||||
// 点击「确认生成视频」
|
||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click()
|
||||
|
||||
// Verify generation was triggered successfully
|
||||
// Verify generation was triggered
|
||||
const genResp = await generatePromise
|
||||
if (!genResp.ok()) {
|
||||
const body = await genResp.text()
|
||||
@@ -258,19 +254,42 @@ test.describe("Core generation flow", () => {
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
|
||||
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Step 5 → Step 6:等待渲染终态
|
||||
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
|
||||
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
|
||||
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
|
||||
const renderSucceeded = await page
|
||||
.getByText("视频生成完成", { exact: false })
|
||||
.waitFor({ timeout: 180_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (renderSucceeded) {
|
||||
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
} else {
|
||||
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
|
||||
}
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
// 创建失败时停留在标题页并展示错误提示
|
||||
await page
|
||||
.getByText(/生成失败|重新生成/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
await page
|
||||
.getByText(/生成中|生成完成|生成失败/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false)
|
||||
// If we see progress or result, great; if not, flow still reached the end
|
||||
// which is sufficient for an E2E smoke test
|
||||
|
||||
// Verify product library page loads (smoke: just verify page renders)
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/app\/products/)
|
||||
|
||||
@@ -20,6 +20,10 @@ export type {
|
||||
// 素材诊断
|
||||
export { getAssetDiagnosis } from "./diagnosis"
|
||||
|
||||
// 素材余量/可用性判断
|
||||
export { isAssetUsable } from "./usage"
|
||||
export type { AssetUsageLike } from "./usage"
|
||||
|
||||
// 素材库
|
||||
export {
|
||||
getAssetLibraries,
|
||||
@@ -39,7 +43,13 @@ export {
|
||||
} from "./assets"
|
||||
|
||||
// 上传
|
||||
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
export {
|
||||
prepareDirectUpload,
|
||||
completeDirectUpload,
|
||||
uploadAssetDirect,
|
||||
prepareDirectUploadHandle,
|
||||
type DirectUploadHandle,
|
||||
} from "./upload"
|
||||
|
||||
// 任务
|
||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface AssetItem {
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number
|
||||
/** 已切片段占用时长占比(0~1,后端片段重复率控制机制返回;字段缺失视为未统计) */
|
||||
used_ratio?: number | null
|
||||
/** 是否已彻底用尽(无新区间且历史区间复用次数均达上限);false 的素材不参与生成选片 */
|
||||
usable?: boolean | null
|
||||
status?: string
|
||||
classification_status?: AssetClassificationStatus | null
|
||||
quality_score?: number | null
|
||||
@@ -129,6 +133,12 @@ export interface DirectUploadPrepareResult {
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
/**
|
||||
* prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。
|
||||
* 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。
|
||||
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
|
||||
*/
|
||||
asset_id?: string
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
@@ -136,4 +146,8 @@ export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
/** 同库已存在相同 file_hash 的素材时为 true,ingest_job_id 为空 */
|
||||
duplicated?: boolean
|
||||
/** duplicated 为 true 时返回已存在素材的 id */
|
||||
asset_id?: string
|
||||
}
|
||||
|
||||
@@ -27,28 +27,18 @@ export const completeDirectUpload = async (data: {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */
|
||||
const putToOSS = (
|
||||
prepared: DirectUploadPrepareResult,
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", file)
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", data.file)
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
|
||||
@@ -56,8 +46,8 @@ export const uploadAssetDirect = async (data: {
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
@@ -102,9 +92,53 @@ export const uploadAssetDirect = async (data: {
|
||||
xhr.send(directForm)
|
||||
})
|
||||
|
||||
return completeDirectUpload({
|
||||
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
|
||||
export interface DirectUploadHandle {
|
||||
/** prepare 返回(含可能的预建 asset_id) */
|
||||
prepared: DirectUploadPrepareResult
|
||||
/** 直传 OSS(可重复调用用于重试) */
|
||||
transfer: (onProgress?: (percent: number) => void) => Promise<void>
|
||||
/** 直传完成后调用 complete 确认入库 */
|
||||
complete: () => Promise<DirectUploadCompleteResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
|
||||
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
|
||||
*/
|
||||
export const prepareDirectUploadHandle = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
}): Promise<DirectUploadHandle> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
|
||||
return {
|
||||
prepared,
|
||||
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
|
||||
complete: () =>
|
||||
completeDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
|
||||
await handle.transfer(data.onProgress)
|
||||
return handle.complete()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 素材余量/可用性判断
|
||||
* 后端片段重复率控制机制(任意两条成片画面重复率 ≤15%)上线后,
|
||||
* 素材列表会附加 usable / used_ratio 字段。字段未上线前一律按可用处理。
|
||||
*/
|
||||
|
||||
/** 仅依赖素材余量相关字段的最小结构,api 层与 pages 层 AssetItem 均可传入 */
|
||||
export interface AssetUsageLike {
|
||||
usable?: boolean | null
|
||||
used_ratio?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材是否仍可参与生成选片。
|
||||
* usable === false 表示已彻底用尽(无新区间且复用次数全部达上限);
|
||||
* 字段缺失(undefined/null)时降级为可用,保证后端字段上线前零影响。
|
||||
*/
|
||||
export const isAssetUsable = (asset: AssetUsageLike): boolean => asset.usable !== false
|
||||
@@ -12,7 +12,14 @@ export interface GenerateCoverTitleConfig {
|
||||
}
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
/**
|
||||
* 封面源视频标识(二选一):
|
||||
* - generated_video_id:确认生成任务产出的最终视频 ID
|
||||
* - video_url:最终视频 URL(兜底)
|
||||
* 后端根据此标识定位最终成片文件并抽帧,MediaKit 选帧逻辑不变
|
||||
*/
|
||||
generated_video_id?: string
|
||||
video_url?: string
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
/** 标题样式,用于在封面上叠加标题文字 */
|
||||
@@ -31,7 +38,7 @@ export interface GenerateCoverResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 生成封面 — 从预览视频中抽帧 */
|
||||
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
|
||||
@@ -44,14 +44,19 @@ export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
/** 创建克隆音色(audio_url 与 asset_id 二选一) */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
// 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆
|
||||
if (data.asset_id) {
|
||||
payload.asset_id = data.asset_id
|
||||
} else {
|
||||
payload.source_audio_url = data.audio_url
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
|
||||
@@ -22,10 +22,13 @@ export interface VoiceClone {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
/** 创建克隆请求(前端简化版:audio_url 与 asset_id 二选一) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string
|
||||
audio_url: string
|
||||
/** 录音/文件上传后的音频 URL(与 asset_id 二选一) */
|
||||
audio_url?: string
|
||||
/** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */
|
||||
asset_id?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -72,11 +75,13 @@ export interface VoiceCloneStatusResponse {
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
/** 后端创建克隆请求(完整版:source_audio_url 与 asset_id 二选一) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
source_audio_url?: string
|
||||
/** 从配音素材选择克隆时传素材 ID */
|
||||
asset_id?: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
|
||||
@@ -149,53 +149,29 @@
|
||||
|
||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-upload-zone {
|
||||
border: 2px dashed var(--xx-color-border, #e5e7eb);
|
||||
/* ── 素材选择空态 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-asset-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--xx-color-border, #e5e7eb);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone:hover {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.03);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone--active {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone--has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
.xx-clonemodal-asset-empty-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,14 +7,5 @@ export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ 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,10 +1,12 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { PROGRESS_STEPS } from "./constants"
|
||||
import { formatRecordTime } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
import type { CloneModalProps, ModalPhase } from "./types"
|
||||
import "./clone-modal.css"
|
||||
@@ -18,15 +20,21 @@ const getExtensionFromMime = (mime: string): string => {
|
||||
return "webm"
|
||||
}
|
||||
|
||||
/** 格式化素材时长(秒 → mm:ss) */
|
||||
const formatAssetDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "--:--"
|
||||
return formatRecordTime(Math.round(seconds))
|
||||
}
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const navigate = useNavigate()
|
||||
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)
|
||||
/** 从配音素材选择的素材 ID */
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string>("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
@@ -34,6 +42,14 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
const isMountedRef = useRef(true)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
/* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */
|
||||
const { data: voiceAssets, isLoading: assetsLoading } = useQuery({
|
||||
queryKey: ["assets", "voice", "clone-modal"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 100 }),
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 录音 Hook ──────────────────────────────────── */
|
||||
const {
|
||||
isRecording,
|
||||
@@ -55,10 +71,9 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
setSelectedAssetId("")
|
||||
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit;
|
||||
// 此前在此上锁且无复位路径,弹窗打开即死锁
|
||||
setErrorMessage("")
|
||||
resetRecorder()
|
||||
}, [getNextDefaultName, resetRecorder])
|
||||
@@ -85,65 +100,26 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────── */
|
||||
/* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */
|
||||
|
||||
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 handleSelectAsset = (assetId: string) => {
|
||||
setSelectedAssetId(assetId)
|
||||
if (assetId) {
|
||||
resetRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
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 handleToggleRecord = () => {
|
||||
// 开始录音会清掉已选素材;停止录音保留录音结果
|
||||
if (!isRecording) {
|
||||
setSelectedAssetId("")
|
||||
}
|
||||
toggleRecord()
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
const hasAudio = selectedAssetId !== "" || recordedBlob !== null
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
const canSubmit = hasAudio && !isProcessing
|
||||
|
||||
@@ -158,7 +134,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
return
|
||||
}
|
||||
if (!hasAudio) {
|
||||
setErrorMessage("请上传音频文件或录制一段声音")
|
||||
setErrorMessage("请从配音素材选择一段音频,或直接录制声音")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,20 +143,37 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
// 路径 A:从配音素材选择 → 无需上传,直接克隆
|
||||
if (selectedAssetId) {
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
description: voiceDescription.trim() || undefined,
|
||||
asset_id: selectedAssetId,
|
||||
})
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
isSubmittingRef.current = false
|
||||
setPhase("done")
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
// 路径 B:录音 → 先上传为配音素材,再克隆
|
||||
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,
|
||||
})
|
||||
}
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
||||
const mimeType = recordedBlob?.type || "audio/webm"
|
||||
const ext = getExtensionFromMime(mimeType)
|
||||
const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
||||
type: mimeType,
|
||||
})
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
@@ -226,6 +219,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}
|
||||
|
||||
const hasAssets = (voiceAssets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -244,7 +239,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<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>
|
||||
<span className="xx-clonemodal-step-label">选择/录制音频</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
@@ -274,30 +269,42 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<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>
|
||||
<label className="xx-clonemodal-label">从配音素材选择</label>
|
||||
{hasAssets ? (
|
||||
<select
|
||||
className="xx-clonemodal-input"
|
||||
value={selectedAssetId}
|
||||
onChange={(e) => handleSelectAsset(e.target.value)}
|
||||
disabled={assetsLoading}
|
||||
>
|
||||
<option value="">{assetsLoading ? "素材加载中…" : "请选择已上传的配音素材"}</option>
|
||||
{voiceAssets!.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.name}({formatAssetDuration(asset.duration)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="xx-clonemodal-asset-empty">
|
||||
<p className="xx-clonemodal-asset-empty-text">
|
||||
{assetsLoading ? "素材加载中…" : "请先在配音库上传素材"}
|
||||
</p>
|
||||
{!assetsLoading && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
handleClose()
|
||||
navigate("/app/voice-materials")
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
@@ -332,7 +339,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={toggleRecord}
|
||||
onClick={handleToggleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
@@ -365,7 +372,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
<span>建议使用 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
@@ -415,18 +422,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
<p className="xx-clonemodal-progress-text">正在上传录音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将录音上传至服务器</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
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)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import UploadZone from "./UploadZone"
|
||||
import RecordArea from "./RecordArea"
|
||||
import StepIndicator from "./StepIndicator"
|
||||
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
|
||||
|
||||
interface InputViewProps {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
errorMessage: string
|
||||
canSubmit: boolean
|
||||
onVoiceNameChange: (value: string) => void
|
||||
onVoiceDescChange: (value: string) => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
onRecordToggle: () => void
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
|
||||
|
||||
const InputView: React.FC<InputViewProps> = ({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
errorMessage,
|
||||
canSubmit,
|
||||
onVoiceNameChange,
|
||||
onVoiceDescChange,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
onRecordToggle,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-body">
|
||||
{/* 步骤引导 */}
|
||||
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
|
||||
|
||||
{/* 音色名称 */}
|
||||
<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) => onVoiceNameChange(e.target.value)}
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={MAX_VOICE_NAME_LENGTH}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">上传音频</label>
|
||||
<UploadZone
|
||||
selectedFile={selectedFile}
|
||||
dragActive={dragActive}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</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>
|
||||
<RecordArea
|
||||
isRecording={isRecording}
|
||||
recordTime={recordTime}
|
||||
recordedBlob={recordedBlob}
|
||||
onRecordToggle={onRecordToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">音色描述</label>
|
||||
<textarea
|
||||
className="xx-clonemodal-textarea"
|
||||
value={voiceDescription}
|
||||
onChange={(e) => onVoiceDescChange(e.target.value)}
|
||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
||||
maxLength={MAX_VOICE_DESC_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
|
||||
</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={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputView
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react"
|
||||
import { PROGRESS_STEPS } from "../constants/cloneModal"
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
|
||||
interface ProgressViewProps {
|
||||
phase: ModalPhase
|
||||
}
|
||||
|
||||
const getProgressIndex = (phase: ModalPhase): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1
|
||||
case "done":
|
||||
return 2
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
|
||||
const progressIndex = getProgressIndex(phase)
|
||||
const isDone = phase === "done"
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
|
||||
const isActive = idx === progressIndex && !isDone
|
||||
const stepDone = idx < progressIndex || isDone
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
stepDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 完成阶段 */}
|
||||
{isDone && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 进行中阶段 */}
|
||||
{!isDone && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressView
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
import { formatRecordTime } from "../utils/cloneModal"
|
||||
|
||||
interface RecordAreaProps {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
onRecordToggle: () => void
|
||||
}
|
||||
|
||||
const RecordArea: React.FC<RecordAreaProps> = ({
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
onRecordToggle,
|
||||
}) => {
|
||||
const getHintText = () => {
|
||||
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
|
||||
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
|
||||
return "点击按钮开始录制(最长 5 分钟)"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-record-area">
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">{getHintText()}</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={onRecordToggle}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordArea
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入阶段顶部的步骤引导(数字步骤)
|
||||
*/
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-steps">
|
||||
{steps.map((label, idx) => {
|
||||
const isActive = idx <= currentStep
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
|
||||
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
|
||||
<div className="xx-clonemodal-step-number">{idx + 1}</div>
|
||||
<span className="xx-clonemodal-step-label">{label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StepIndicator
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { useRef } from "react"
|
||||
import { ACCEPTED_MIME } from "../constants/cloneModal"
|
||||
import { validateFile } from "../utils/cloneModal"
|
||||
|
||||
interface UploadZoneProps {
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
}
|
||||
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
selectedFile,
|
||||
dragActive,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
onDragActiveChange(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
onDragActiveChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragActiveChange(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
|
||||
/** 进度阶段配置 */
|
||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
|
||||
/** input accept 属性值 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长(秒):5 分钟 */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/** 音色名称最小长度 */
|
||||
export const MIN_VOICE_NAME_LENGTH = 2
|
||||
|
||||
/** 音色名称最大长度 */
|
||||
export const MAX_VOICE_NAME_LENGTH = 20
|
||||
|
||||
/** 音色描述最大长度 */
|
||||
export const MAX_VOICE_DESC_LENGTH = 100
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
|
||||
|
||||
interface UseAudioRecorderReturn {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
toggleRecording: () => void
|
||||
resetRecording: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
|
||||
*/
|
||||
const useAudioRecorder = (): UseAudioRecorderReturn => {
|
||||
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 stopRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
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 = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||
setRecordedBlob(blob)
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
setTimeout(() => {
|
||||
stopRecording()
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
// 错误由调用方通过其他机制提示
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [stopRecording])
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}, [isRecording, startRecording, stopRecording])
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
}, [])
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
toggleRecording,
|
||||
resetRecording,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAudioRecorder
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCloneModal
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, 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",
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
|
||||
// 阶段 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 }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 弹窗阶段 */
|
||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/** 进度步骤项 */
|
||||
export interface ProgressStep {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
|
||||
|
||||
/**
|
||||
* 格式化录制时间 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")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传的音频文件
|
||||
* @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
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
|
||||
import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
|
||||
import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
|
||||
import AssetUploadZone from "@/pages/assets/components/AssetUploadZone"
|
||||
import UploadQueuePanel from "@/pages/assets/components/UploadQueuePanel"
|
||||
import AssetGridSection from "@/pages/assets/components/AssetGridSection"
|
||||
import AssetModals from "@/pages/assets/components/AssetModals"
|
||||
import { useAssetsData } from "@/pages/assets/hooks/useAssetsData"
|
||||
@@ -69,7 +70,30 @@ const AssetLibrary: React.FC = () => {
|
||||
})
|
||||
|
||||
/* ── 上传 ── */
|
||||
const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId })
|
||||
const {
|
||||
uploadItems,
|
||||
enqueueUploads,
|
||||
retryUpload,
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
uploading,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
} = useAssetUpload({ effectiveLibId })
|
||||
|
||||
/* ── 上传中 asset_id → 进度/状态映射,合并进网格卡片展示真实进度 ── */
|
||||
const uploadProgressMap = React.useMemo(() => {
|
||||
const map = new Map<string, { progress: number; uploading: boolean }>()
|
||||
for (const it of uploadItems) {
|
||||
if (it.assetId && (it.status === "uploading" || it.status === "ingesting")) {
|
||||
map.set(it.assetId, {
|
||||
progress: it.status === "ingesting" ? 100 : it.progress,
|
||||
uploading: it.status === "uploading",
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [uploadItems])
|
||||
|
||||
/* ── 选中态管理 ── */
|
||||
const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({
|
||||
@@ -144,8 +168,17 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* 上传区域 */}
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onUpload={handleUpload}
|
||||
activeCount={activeCount}
|
||||
pendingCount={pendingCount}
|
||||
onUpload={enqueueUploads}
|
||||
/>
|
||||
|
||||
{/* 上传队列:独立进度 + 失败重试/移除 */}
|
||||
<UploadQueuePanel
|
||||
items={uploadItems}
|
||||
onRetry={retryUpload}
|
||||
onRemove={removeUpload}
|
||||
onClearFinished={clearFinished}
|
||||
/>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
@@ -180,6 +213,7 @@ const AssetLibrary: React.FC = () => {
|
||||
assets={filteredAssets}
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
uploadProgressMap={uploadProgressMap}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
@@ -191,8 +225,6 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
{/* ─── 弹窗集合 ─── */}
|
||||
<AssetModals
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
createModalOpen={createModalOpen}
|
||||
onCreateModalCancel={() => setCreateModalOpen(false)}
|
||||
onCreateModalOk={handleCreateLibrary}
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
============================================================ */
|
||||
.xx-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
.xx-asset-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.xx-asset-card:active {
|
||||
@@ -244,7 +244,7 @@
|
||||
|
||||
/* 缩略图 */
|
||||
.xx-asset-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 3 / 4;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
@@ -260,22 +260,22 @@
|
||||
}
|
||||
|
||||
.xx-asset-thumb-placeholder {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-size: var(--font-size-xl);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 播放按钮 */
|
||||
.xx-asset-play {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
@@ -332,8 +332,8 @@
|
||||
position: absolute;
|
||||
bottom: var(--space-sm, 8px);
|
||||
right: var(--space-sm, 8px);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -380,12 +380,12 @@
|
||||
|
||||
/* 卡片信息 */
|
||||
.xx-asset-info {
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.xx-asset-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0 0 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
@@ -402,6 +402,44 @@
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 状态标签 + 余量角标行 */
|
||||
.xx-asset-meta-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 视频素材余量角标(仅状态展示,不影响卡片操作) */
|
||||
.xx-asset-usage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 已用尽:红色实心 */
|
||||
.xx-asset-usage-badge-exhausted {
|
||||
background: var(--error-color);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* 即将用尽:红色软底 */
|
||||
.xx-asset-usage-badge-warning {
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* 已用 xx%:橙色软底 */
|
||||
.xx-asset-usage-badge-ratio {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* 诊断按钮 */
|
||||
.xx-asset-diagnose-btn {
|
||||
width: 100%;
|
||||
@@ -662,41 +700,126 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 上传进度弹窗 ─── */
|
||||
.xx-upload-progress-modal .ant-modal-content {
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 16px;
|
||||
/* ─── 上传队列面板 ─── */
|
||||
.xx-upload-queue {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-upload-progress-body {
|
||||
.xx-upload-queue-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-primary, #eef2f7);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.xx-upload-progress-ring {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-upload-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-upload-progress-label {
|
||||
.xx-upload-queue-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-upload-queue-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-upload-queue-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-item + .xx-upload-queue-item {
|
||||
border-top: 1px solid var(--border-primary, #f1f5f9);
|
||||
}
|
||||
|
||||
.xx-upload-queue-icon {
|
||||
padding-top: 2px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-upload-queue-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #1e293b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xx-upload-queue-progress {
|
||||
margin-top: 6px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-primary, #e5e7eb);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-upload-queue-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-color, #6366f1);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.xx-upload-queue-status {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-upload-queue-error .xx-upload-queue-status {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-upload-queue-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-btn:hover {
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
/* ─── 素材卡片上传中遮罩进度条 ─── */
|
||||
.xx-asset-thumb-uploading {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-asset-upload-bar {
|
||||
width: 70%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-asset-upload-bar-inner {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
/* ─── 批量打标签弹窗 ─── */
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import { getUsageBadge, type AssetItem } from "@/pages/assets/types"
|
||||
import { thumbGradient } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
import { StatusPill } from "./AssetSkeleton"
|
||||
@@ -20,6 +20,8 @@ export interface AssetCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
diagnosing?: boolean
|
||||
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
|
||||
uploadProgress?: { progress: number; uploading: boolean }
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
@@ -30,99 +32,126 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
uploadProgress,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
}) => {
|
||||
const isUploading = !!uploadProgress?.uploading
|
||||
// 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null
|
||||
const usageBadge = getUsageBadge(asset)
|
||||
return (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
{/* 上传中遮罩:真实进度百分比 + 进度条 */}
|
||||
{isUploading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-uploading">
|
||||
<LoadingOutlined />
|
||||
<span>上传中 {uploadProgress?.progress ?? 0}%</span>
|
||||
<div className="xx-asset-upload-bar">
|
||||
<div
|
||||
className="xx-asset-upload-bar-inner"
|
||||
style={{ width: `${uploadProgress?.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 转码/处理中遮罩 */}
|
||||
{asset.loading && !isUploading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>转码处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<span className="xx-asset-meta-left">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{usageBadge && (
|
||||
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}>
|
||||
{usageBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetCard
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { AssetItem } from "../types"
|
||||
import AssetCard from "./AssetCard"
|
||||
import { SkeletonCard } from "./AssetSkeleton"
|
||||
|
||||
/** 上传中素材的实时进度(asset_id → 进度信息),由上传队列合并到卡片 */
|
||||
export type UploadProgressMap = Map<string, { progress: number; uploading: boolean }>
|
||||
|
||||
export interface AssetGridSectionProps {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
@@ -15,6 +18,7 @@ export interface AssetGridSectionProps {
|
||||
assets: AssetItem[]
|
||||
selectedIds: Set<string>
|
||||
diagnosingId: string | null
|
||||
uploadProgressMap?: UploadProgressMap
|
||||
onRetry?: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onDiagnose: (asset: AssetItem) => void
|
||||
@@ -29,6 +33,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
assets,
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
uploadProgressMap,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
@@ -70,6 +75,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
uploadProgress={uploadProgressMap?.get(asset.id)}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
|
||||
@@ -11,12 +11,9 @@ import BatchTagModal from "./BatchTagModal"
|
||||
import BatchClassifyModal from "./BatchClassifyModal"
|
||||
import BatchMarkModal from "./BatchMarkModal"
|
||||
import ResultDrawer from "./ResultDrawer"
|
||||
import UploadProgressModal from "./UploadProgressModal"
|
||||
|
||||
export interface AssetModalsProps {
|
||||
/* 上传进度 */
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
|
||||
/* 新建视频库 */
|
||||
createModalOpen: boolean
|
||||
@@ -68,8 +65,6 @@ export interface AssetModalsProps {
|
||||
}
|
||||
|
||||
export const AssetModals: React.FC<AssetModalsProps> = ({
|
||||
uploading,
|
||||
uploadProgress,
|
||||
createModalOpen,
|
||||
onCreateModalCancel,
|
||||
onCreateModalOk,
|
||||
@@ -109,9 +104,6 @@ export const AssetModals: React.FC<AssetModalsProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 上传进度弹窗 */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 新建视频库弹窗 */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* AssetLibrary 上传拖拽区域
|
||||
* 多文件拖入即入队(并发由 useAssetUpload 队列控制,最多 3 路直传)
|
||||
*/
|
||||
import React from "react"
|
||||
import { Upload } from "antd"
|
||||
@@ -7,15 +8,23 @@ import { InboxOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
onUpload: (file: File) => void
|
||||
activeCount: number
|
||||
pendingCount: number
|
||||
onUpload: (files: File[]) => void
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
onUpload,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
onUpload(file as File)
|
||||
// antd 多选时会对每个文件同步连续触发一次 beforeUpload;
|
||||
// 每次只入队当前文件,React 批处理保证多文件一次性渲染
|
||||
onUpload([file as File])
|
||||
return false
|
||||
}}
|
||||
showUploadList={false}
|
||||
@@ -27,9 +36,11 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onU
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
{uploading
|
||||
? `上传中…(进行 ${activeCount} 个${pendingCount > 0 ? `,排队 ${pendingCount} 个` : ""})`
|
||||
: "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB;多文件自动排队上传</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
|
||||
* ============================================================ */
|
||||
export interface UploadProgressModalProps {
|
||||
open: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
|
||||
<AntModal
|
||||
open={open}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{progress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default UploadProgressModal
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 上传队列面板
|
||||
* 展示批量上传中每个文件的独立状态/进度;失败可重试、可移除、可清空已完成。
|
||||
* 上传中的素材卡片同时也会出现在素材网格(后端 prepare 预建 asset),
|
||||
* 此面板用于展示真实传输进度与失败重试入口。
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { UploadItem } from "../hooks/useAssetUpload"
|
||||
|
||||
export interface UploadQueuePanelProps {
|
||||
items: UploadItem[]
|
||||
onRetry: (tempId: string) => void
|
||||
onRemove: (tempId: string) => void
|
||||
onClearFinished: () => void
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<UploadItem["status"], string> = {
|
||||
preparing: "排队中…",
|
||||
uploading: "上传中",
|
||||
ingesting: "转码中…",
|
||||
done: "已完成",
|
||||
error: "上传失败",
|
||||
}
|
||||
|
||||
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
items,
|
||||
onRetry,
|
||||
onRemove,
|
||||
onClearFinished,
|
||||
}) => {
|
||||
if (items.length === 0) return null
|
||||
const finishedCount = items.filter((it) => it.status === "done").length
|
||||
|
||||
return (
|
||||
<div className="xx-upload-queue">
|
||||
<div className="xx-upload-queue-header">
|
||||
<span className="xx-upload-queue-title">
|
||||
上传任务({items.length}
|
||||
{finishedCount > 0 ? `,已完成 ${finishedCount}` : ""})
|
||||
</span>
|
||||
{finishedCount > 0 && (
|
||||
<button type="button" className="xx-link-btn" onClick={onClearFinished}>
|
||||
清空已完成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-upload-queue-list">
|
||||
{items.map((it) => {
|
||||
const isActive = it.status === "preparing" || it.status === "uploading"
|
||||
const showProgress = it.status === "uploading" || it.status === "ingesting"
|
||||
return (
|
||||
<div key={it.tempId} className={`xx-upload-queue-item xx-upload-queue-${it.status}`}>
|
||||
<span className="xx-upload-queue-icon">
|
||||
{it.status === "done" || it.duplicated ? (
|
||||
<CheckCircleFilled style={{ color: "#22c55e" }} />
|
||||
) : it.status === "error" ? (
|
||||
<CloseCircleFilled style={{ color: "#ef4444" }} />
|
||||
) : (
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)" }} />
|
||||
)}
|
||||
</span>
|
||||
<div className="xx-upload-queue-body">
|
||||
<div className="xx-upload-queue-name" title={it.fileName}>
|
||||
{it.fileName}
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="xx-upload-queue-progress">
|
||||
<div
|
||||
className="xx-upload-queue-progress-bar"
|
||||
style={{ width: `${it.status === "ingesting" ? 100 : it.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="xx-upload-queue-status">
|
||||
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
|
||||
{it.status === "uploading" ? ` ${it.progress}%` : ""}
|
||||
{it.status === "error" && it.error ? `:${it.error}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="xx-upload-queue-actions">
|
||||
{it.status === "error" && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-upload-queue-btn"
|
||||
title="重试"
|
||||
onClick={() => onRetry(it.tempId)}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
)}
|
||||
{(it.status === "error" || it.status === "done") && !isActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-upload-queue-btn"
|
||||
title="移除"
|
||||
onClick={() => onRemove(it.tempId)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadQueuePanel
|
||||
@@ -1,65 +1,196 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { uploadAssetDirect } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
|
||||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE } from "../constants"
|
||||
|
||||
/**
|
||||
* 素材上传 Hook
|
||||
* 封装上传状态、进度管理和上传逻辑
|
||||
*/
|
||||
interface UseAssetUploadProps {
|
||||
effectiveLibId: string
|
||||
/** 单文件上传状态机 */
|
||||
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
|
||||
|
||||
export interface UploadItem {
|
||||
/** 前端临时 id(prepare 前无 asset_id 时用) */
|
||||
tempId: string
|
||||
file: File
|
||||
fileName: string
|
||||
/** 进度 0~100(仅直传阶段有真实进度) */
|
||||
progress: number
|
||||
status: UploadItemStatus
|
||||
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
|
||||
assetId?: string
|
||||
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
|
||||
duplicated?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
|
||||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
/**
|
||||
* 素材批量上传 Hook
|
||||
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
|
||||
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
|
||||
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
|
||||
* - 失败卡片支持重试/移除
|
||||
*/
|
||||
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [items, setItems] = useState<UploadItem[]>([])
|
||||
const itemsRef = useRef<UploadItem[]>([])
|
||||
itemsRef.current = items
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
return
|
||||
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
|
||||
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
|
||||
}, [])
|
||||
|
||||
/** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */
|
||||
const refreshList = useCallback(() => {
|
||||
// 使用 refetchQueries 强制立即重新获取,避免 staleTime 导致延迟
|
||||
if (effectiveLibId) {
|
||||
queryClient.refetchQueries({ queryKey: ["assets", effectiveLibId] })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient, effectiveLibId])
|
||||
|
||||
/** 执行单个文件的完整上传流程(prepare→transfer→complete) */
|
||||
const runUpload = useCallback(
|
||||
async (item: UploadItem, handle?: DirectUploadHandle) => {
|
||||
try {
|
||||
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
|
||||
const h =
|
||||
handle ??
|
||||
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
|
||||
if (h.prepared.asset_id) {
|
||||
updateItem(item.tempId, {
|
||||
status: "uploading",
|
||||
assetId: h.prepared.asset_id,
|
||||
progress: 0,
|
||||
})
|
||||
// 预建 asset 已入库,立即刷新让「上传中」卡片出现在网格
|
||||
refreshList()
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "uploading", progress: 0 })
|
||||
}
|
||||
|
||||
// 2. OSS 直传(真实进度)
|
||||
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
|
||||
|
||||
// 3. complete:后端创建 ingest job,素材进入转码
|
||||
updateItem(item.tempId, { status: "ingesting", progress: 100 })
|
||||
const result = await h.complete()
|
||||
refreshList()
|
||||
|
||||
if (result.duplicated) {
|
||||
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "done" })
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "上传失败"
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
|
||||
updateItem(item.tempId, { status: "error", error: detail })
|
||||
message.error(`"${item.fileName}" 上传失败:${detail}`)
|
||||
}
|
||||
},
|
||||
[effectiveLibId, refreshList, updateItem],
|
||||
)
|
||||
|
||||
/**
|
||||
* 队列调度:把并发槽塞满(同时在途的 prepare+transfer 不超过 MAX_CONCURRENT)。
|
||||
* runUpload 在 await prepare 期间 state 仍是 preparing,多个并发 pump 若只看 state
|
||||
* 会重复认领同一项,因此用 claimedRef 记录已被认领的 tempId。
|
||||
*/
|
||||
const inFlightRef = useRef(0)
|
||||
const claimedRef = useRef<Set<string>>(new Set())
|
||||
const pumpRef = useRef<() => void>(() => {})
|
||||
pumpRef.current = () => {
|
||||
while (inFlightRef.current < MAX_CONCURRENT) {
|
||||
const next = itemsRef.current.find(
|
||||
(it) => it.status === "preparing" && !claimedRef.current.has(it.tempId),
|
||||
)
|
||||
if (!next) return
|
||||
claimedRef.current.add(next.tempId)
|
||||
inFlightRef.current += 1
|
||||
void runUpload(next).finally(() => {
|
||||
inFlightRef.current -= 1
|
||||
claimedRef.current.delete(next.tempId)
|
||||
// 一个任务结束(成功/失败)后继续拉起排队任务
|
||||
setTimeout(() => pumpRef.current(), 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
pumpRef.current()
|
||||
}, [items])
|
||||
|
||||
/** 入队一个或多个文件 */
|
||||
const enqueueUploads = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`)
|
||||
const valid: File[] = []
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
continue
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
})
|
||||
message.success(`"${file.name}" 上传成功`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : ""
|
||||
console.error("[handleUpload] 上传失败:", err)
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
valid.push(file)
|
||||
}
|
||||
if (valid.length === 0) return
|
||||
|
||||
const newItems: UploadItem[] = valid.map((file, idx) => ({
|
||||
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
}))
|
||||
setItems((prev) => [...prev, ...newItems])
|
||||
},
|
||||
[effectiveLibId, queryClient],
|
||||
[effectiveLibId],
|
||||
)
|
||||
|
||||
/** 重试失败任务 */
|
||||
const retryUpload = useCallback(
|
||||
(tempId: string) => {
|
||||
const target = itemsRef.current.find((it) => it.tempId === tempId)
|
||||
if (!target) return
|
||||
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
|
||||
// 状态更新后由 useEffect 触发 pump
|
||||
},
|
||||
[updateItem],
|
||||
)
|
||||
|
||||
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
|
||||
const removeUpload = useCallback((tempId: string) => {
|
||||
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
|
||||
}, [])
|
||||
|
||||
/** 清空已完成/去重记录 */
|
||||
const clearFinished = useCallback(() => {
|
||||
setItems((prev) => prev.filter((it) => it.status !== "done"))
|
||||
}, [])
|
||||
|
||||
const activeCount = items.filter(
|
||||
(it) => it.status === "preparing" || it.status === "uploading",
|
||||
).length
|
||||
const pendingCount = items.filter((it) => it.status === "preparing").length
|
||||
const hasActive = activeCount > 0 || items.some((it) => it.status === "ingesting")
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadProgress,
|
||||
handleUpload,
|
||||
uploadItems: items,
|
||||
enqueueUploads,
|
||||
retryUpload,
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
/** 是否有进行中的上传(用于上传区文案) */
|
||||
uploading: hasActive,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,21 @@ export function useAssetsData() {
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"上传中/处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as { items: ApiAssetItem[] } | undefined
|
||||
const items = data?.items ?? []
|
||||
const processing = items.some((a) => {
|
||||
const st = a.status ?? ""
|
||||
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
|
||||
})
|
||||
return processing ? 3000 : false
|
||||
},
|
||||
})
|
||||
|
||||
const assets: AssetItem[] = useMemo(
|
||||
|
||||
@@ -27,6 +27,36 @@ export interface AssetItem {
|
||||
duration?: string
|
||||
size: number
|
||||
createdAt: string
|
||||
/** 已切片段占用时长占比(0~1),后端字段缺失时为 undefined */
|
||||
usedRatio?: number
|
||||
/** 是否已彻底用尽(false 的素材不参与生成选片),字段缺失时视为可用 */
|
||||
usable?: boolean
|
||||
}
|
||||
|
||||
/** 素材余量角标状态(仅视频素材) */
|
||||
export interface UsageBadge {
|
||||
/** 角标文案 */
|
||||
label: string
|
||||
/** 样式变体:exhausted=红色实心,warning=红色软底,ratio=橙色软底 */
|
||||
variant: "exhausted" | "warning" | "ratio"
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据后端余量字段计算视频素材的余量角标;
|
||||
* 非视频、字段缺失或已用占比 <50% 时不显示(返回 null)。
|
||||
*/
|
||||
export const getUsageBadge = (asset: {
|
||||
kind?: AssetKind
|
||||
usable?: boolean
|
||||
usedRatio?: number
|
||||
}): UsageBadge | null => {
|
||||
if (asset.kind && asset.kind !== "video") return null
|
||||
if (asset.usable === false) return { label: "已用尽", variant: "exhausted" }
|
||||
const ratio = asset.usedRatio
|
||||
if (ratio == null) return null
|
||||
if (ratio >= 0.85) return { label: "即将用尽", variant: "warning" }
|
||||
if (ratio >= 0.5) return { label: `已用 ${Math.round(ratio * 100)}%`, variant: "ratio" }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
@@ -111,5 +141,7 @@ export const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
|
||||
usedRatio: item.used_ratio ?? undefined,
|
||||
usable: item.usable ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 前端实时预览架构
|
||||
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
|
||||
*
|
||||
* 架构:
|
||||
* - 步骤 4-6 右侧显示 FrontendPreviewPlayer 实时预览
|
||||
* - 步骤 7 右侧内联播放生成的最终视频
|
||||
* - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
|
||||
* - 步骤 5 右侧内联播放生成中的/最终视频
|
||||
* - 步骤 6 封面从最终成片中智能选帧(MediaKit)
|
||||
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react"
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
@@ -163,7 +163,16 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
|
||||
previewAssetIds,
|
||||
previewAssetsEnabled,
|
||||
)
|
||||
|
||||
/* ── 预览就绪:素材已加载,且有模板 ── */
|
||||
const previewReady = useMemo(
|
||||
() => previewAssetsReady && !!currentTemplate,
|
||||
[previewAssetsReady, currentTemplate],
|
||||
)
|
||||
|
||||
/* ── 视频总时长计算 ── */
|
||||
const totalVideoDuration = useMemo(() => {
|
||||
@@ -172,17 +181,6 @@ const GeneratePage: React.FC = () => {
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -219,6 +217,38 @@ const GeneratePage: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览视频正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
const ok = await handleGenerate()
|
||||
if (ok) {
|
||||
setCurrentStep(5)
|
||||
}
|
||||
}, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
generated,
|
||||
})
|
||||
|
||||
/* ── 最终成片(步骤5/6 右侧播放) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
================================================================ */
|
||||
@@ -255,13 +285,10 @@ const GeneratePage: React.FC = () => {
|
||||
onApplyPreset={styleUpdaters.applyPreset}
|
||||
activePreset={styleUpdaters.activePreset}
|
||||
titlePresets={styleUpdaters.titlePresets}
|
||||
onPreviewTaskCreated={setPreviewTaskId}
|
||||
onSourceEditPlanIdExtracted={setStoredSourceEditPlanId}
|
||||
bgm={bgm}
|
||||
bgmConfig={bgmConfig}
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
@@ -289,16 +316,16 @@ const GeneratePage: React.FC = () => {
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onGenerate={handleGenerate}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:步骤 4-6 实时预览,步骤 7 最终视频 ════ */}
|
||||
{/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{currentStep >= 4 && currentStep <= 6 && !!currentTemplate && (
|
||||
{currentStep === 4 && !!currentTemplate && (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
@@ -319,14 +346,14 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 7 && generated && generatedVideos.length > 0 && (
|
||||
{currentStep >= 5 && generated && finalVideo && (
|
||||
<div className="xx-inline-video-player">
|
||||
<video
|
||||
src={generatedVideos[0].download_url || generatedVideos[0].file_url}
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
autoPlay={currentStep === 5}
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
poster={generatedVideos[0].thumbnail_url || undefined}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮
|
||||
*
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳转步骤5)
|
||||
* 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面)
|
||||
* 步骤 6(选择封面):仅上一步
|
||||
*/
|
||||
import React from "react"
|
||||
import { ThunderboltOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface GenerateStepActionsProps {
|
||||
currentStep: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onGenerate: () => void
|
||||
/** 步骤4:确认生成视频(校验 + 创建渲染任务 + 成功后进入步骤5) */
|
||||
onConfirmGenerate: () => void | Promise<void>
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -18,36 +23,74 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
currentStep,
|
||||
onPrev,
|
||||
onNext,
|
||||
onGenerate,
|
||||
onConfirmGenerate,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
}) => {
|
||||
const renderPrimaryButton = () => {
|
||||
/* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
|
||||
if (currentStep < 4) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 4:确认生成视频(触发按钮在标题页) */
|
||||
if (currentStep === 4) {
|
||||
if (generating) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
⏳ 视频生成中…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (generateError) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
🔄 重新生成视频
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
✨ 确认生成视频
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5:渲染中禁用,完成后下一步进入封面 */
|
||||
if (currentStep === 5) {
|
||||
return (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onNext}
|
||||
disabled={generating || !generated}
|
||||
>
|
||||
{generating ? "视频生成中…" : "下一步 →"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 6(最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-step-actions">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={onPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 7 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
{renderPrimaryButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
|
||||
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -15,10 +12,9 @@ import type { TitleSettings } from "../types"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step5GeneratePreview from "../components/Step5GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
export interface GenerateStepContentProps {
|
||||
@@ -37,7 +33,6 @@ export interface GenerateStepContentProps {
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/* 标题样式回调 — Step5 样式面板使用 */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
@@ -51,7 +46,6 @@ export interface GenerateStepContentProps {
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
@@ -76,10 +70,6 @@ export interface GenerateStepContentProps {
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
/** 预览任务创建回调——传递给 Step6CoverSettings */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** BGM 开关 */
|
||||
bgm: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
@@ -112,7 +102,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
@@ -128,10 +117,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
} = props
|
||||
|
||||
/* 当前模板的 segments,传给 Step2 构建 clips */
|
||||
@@ -175,12 +160,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
titleSettings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
@@ -193,27 +172,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets={titlePresets}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
case 5:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
onPreviewTaskCreated={onPreviewTaskCreated}
|
||||
onSourceEditPlanIdExtracted={onSourceEditPlanIdExtracted}
|
||||
voiceMode={voiceMode}
|
||||
selectedVoice={selectedVoice}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
bgm={bgm}
|
||||
bgmConfig={bgmConfig}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
return (
|
||||
<Step7ConfirmGenerate
|
||||
<Step5ConfirmGenerate
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
@@ -235,6 +196,16 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
generatedVideos={generatedVideos}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -62,8 +62,9 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<ManualMaterialList
|
||||
materials={m.materials}
|
||||
materials={m.selectableMaterials}
|
||||
materialsLoading={m.materialsLoading}
|
||||
allExhausted={m.allMaterialsExhausted}
|
||||
selectedMaterials={m.selectedMaterials}
|
||||
onToggle={m.handleToggleMaterial}
|
||||
/>
|
||||
@@ -77,21 +78,11 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
onMatch={m.handleSmartMatch}
|
||||
hasMatched={m.hasMatched}
|
||||
onRefresh={m.handleRefreshMatch}
|
||||
materialsCount={m.materials.items.length}
|
||||
materialsCount={m.selectableMaterials.items.length}
|
||||
loading={m.materialsLoading}
|
||||
/>
|
||||
|
||||
<SmartMatchResults
|
||||
matchedAssets={m.smartMatchedResults}
|
||||
selectedIds={m.smartSelectedIds}
|
||||
matching={m.smartMatching}
|
||||
hasMatched={m.hasMatched}
|
||||
onToggle={m.handleToggleSmartSelect}
|
||||
onSelectAll={m.handleSelectAllMatched}
|
||||
onClear={m.handleClearSmartSelect}
|
||||
formatDuration={m.formatDuration}
|
||||
selectedTotalDuration={m.smartSelectedTotalDuration}
|
||||
/>
|
||||
<SmartMatchResults matching={m.smartMatching} hasMatched={m.hasMatched} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
* 仅包含标题文字输入 + AI 标题生成
|
||||
* 标题样式面板已迁移到 Step5(生成预览页面)
|
||||
* Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板)
|
||||
*
|
||||
* 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设)
|
||||
* 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
|
||||
*/
|
||||
import React from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { PlayCircleOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
/* 标题样式回调 */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
const {
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
@@ -112,6 +139,42 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 标题样式面板(原 Step5) */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
background: "rgba(59, 130, 246, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
border: "1px solid rgba(59, 130, 246, 0.15)",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
|
||||
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
|
||||
右侧为实时预览,调整样式即时生效
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Step 5 预览设置组件
|
||||
*
|
||||
* 前端实时预览架构:
|
||||
* - 右侧面板使用 FrontendPreviewPlayer 实时播放素材片段
|
||||
* - 标题样式可实时调整,CSS 层即时叠加预览
|
||||
* - 点"确认生成"时触发一次服务器渲染
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined } from "@ant-design/icons"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step5GeneratePreviewProps {
|
||||
titleSettings: TitleSettings
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
}
|
||||
|
||||
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
titleSettings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 预览设置</h3>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "12px 16px",
|
||||
background: "rgba(59, 130, 246, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: "1px solid rgba(59, 130, 246, 0.15)",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
右侧为实时预览,选完素材即可播放。确认生成后服务器渲染最终视频
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5GeneratePreview
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
@@ -9,27 +11,12 @@ import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: import("../types").TitleSettings
|
||||
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** 配音模式 */
|
||||
voiceMode?: "preset" | "custom" | "clone"
|
||||
/** 选中的配音素材 ID */
|
||||
selectedVoice?: string
|
||||
/** 选中的克隆音色 ID */
|
||||
selectedClonedVoice?: string
|
||||
/** BGM 开关 */
|
||||
bgm?: boolean
|
||||
/** BGM 配置 */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -37,6 +24,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
finalVideo,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
@@ -53,17 +41,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
onPreviewTaskCreated: props.onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted: props.onSourceEditPlanIdExtracted,
|
||||
voiceMode: props.voiceMode,
|
||||
selectedVoice: props.selectedVoice,
|
||||
selectedClonedVoice: props.selectedClonedVoice,
|
||||
bgm: props.bgm,
|
||||
bgmConfig: props.bgmConfig,
|
||||
generatedVideos: props.generatedVideos,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
@@ -77,8 +57,25 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 最终成片信息 */}
|
||||
{finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
background: "rgba(16, 185, 129, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: "1px solid rgba(16, 185, 129, 0.15)",
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate}>
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
@@ -126,7 +123,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI 正在生成封面,请稍候...</p>
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
AI 正在从最终成片选帧,请稍候...
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ const { Text } = Typography
|
||||
interface ManualMaterialListProps {
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
materialsLoading: boolean
|
||||
/** 库内有素材但全部已用尽(usable === false),用于区分空状态文案 */
|
||||
allExhausted?: boolean
|
||||
selectedMaterials: string[]
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
@@ -247,6 +249,7 @@ const MaterialCard: React.FC<{
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
allExhausted,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
@@ -256,7 +259,9 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在视频库中上传
|
||||
{allExhausted
|
||||
? "暂无可选素材(素材可能已用尽,请先上传新素材)"
|
||||
: "暂无素材,请先在视频库中上传"}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* 智能匹配卡片(Q5 简化版)
|
||||
* 只展示素材缩略图、名称、时长,无匹配分数
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SmartMatchCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
onClick,
|
||||
formatDuration,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-smart-match-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="xx-smart-match-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="xx-smart-match-check">
|
||||
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
|
||||
</div>
|
||||
)}
|
||||
{asset.duration && (
|
||||
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="xx-smart-match-info">
|
||||
<div className="xx-smart-match-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchCard
|
||||
@@ -1,35 +1,17 @@
|
||||
/**
|
||||
* 智能匹配结果区(Q5 简化版)
|
||||
* 直接展示 AI 选中的素材,无匹配分数和理由
|
||||
* 智能匹配状态区
|
||||
* 仅展示匹配中 / 未匹配 / 匹配成功三种状态,不展示 AI 选中的素材明细
|
||||
* (选中的素材仍由 smartSelectedIds 驱动提交,逻辑不变)
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import SmartMatchCard from "./SmartMatchCard"
|
||||
|
||||
interface SmartMatchResultsProps {
|
||||
matchedAssets: AssetItem[]
|
||||
selectedIds: string[]
|
||||
matching: boolean
|
||||
hasMatched: boolean
|
||||
onToggle: (assetId: string) => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
selectedTotalDuration: number
|
||||
}
|
||||
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
matchedAssets,
|
||||
selectedIds,
|
||||
matching,
|
||||
hasMatched,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
formatDuration,
|
||||
selectedTotalDuration,
|
||||
}) => {
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({ matching, hasMatched }) => {
|
||||
// 匹配中状态
|
||||
if (matching) {
|
||||
return (
|
||||
@@ -45,66 +27,26 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 未匹配状态提示
|
||||
if (!hasMatched) {
|
||||
// 匹配成功:轻量提示,不展示素材明细卡片
|
||||
if (hasMatched) {
|
||||
return (
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
点击「让 AI 帮你选」,自动从视频库中选择最合适的素材
|
||||
</div>
|
||||
<div className="xx-smart-match-success">
|
||||
<span style={{ fontSize: 16 }}>✅</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
AI 已帮你选好素材,可直接进入下一步
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 无结果
|
||||
if (matchedAssets.length === 0) return null
|
||||
|
||||
// 未匹配状态提示
|
||||
return (
|
||||
<>
|
||||
<div className="xx-smart-match-results">
|
||||
<div className="xx-smart-match-results-header">
|
||||
<span className="xx-smart-match-results-title">
|
||||
AI 已选素材 ({matchedAssets.length}个)
|
||||
</span>
|
||||
<div className="xx-smart-match-results-actions">
|
||||
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
|
||||
全选
|
||||
</button>
|
||||
<span style={{ color: "var(--border-color)" }}>|</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-smart-match-grid">
|
||||
{matchedAssets.map((asset) => {
|
||||
const isSelected = selectedIds.includes(asset.id)
|
||||
return (
|
||||
<SmartMatchCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={isSelected}
|
||||
onClick={() => onToggle(asset.id)}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
点击「让 AI 帮你选」,自动从视频库中选择最合适的素材
|
||||
</div>
|
||||
|
||||
{/* 已选素材汇总 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="xx-smart-match-summary">
|
||||
<div className="xx-smart-match-summary-header">
|
||||
<span className="xx-pill xx-pill-ok">已选 {selectedIds.length} 个素材</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
|
||||
预计总时长约 {selectedTotalDuration.toFixed(0)} 秒
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,21 @@ const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
if (!generating && !generated && !generateError) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{!generating && !generated && !generateError && (
|
||||
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">🎬</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
|
||||
@@ -33,9 +33,8 @@ export const STEPS = [
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "生成预览" },
|
||||
{ key: 5, label: "确认生成" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
/* ── 标题位置选项 ── */
|
||||
|
||||
@@ -1409,154 +1409,15 @@
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-results-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-smart-match-results-actions {
|
||||
.xx-smart-match-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.xx-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xx-smart-match-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-card {
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
padding: 18px 20px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-smart-match-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.xx-smart-match-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-score {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-info {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-reason {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-smart-match-loading {
|
||||
@@ -1580,19 +1441,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-summary {
|
||||
padding: 12px 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AI 智能生成标题
|
||||
============================================================ */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import { getAssets, getAssetLibraries, isAssetUsable } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/**
|
||||
@@ -31,11 +31,26 @@ export function useMaterialLibrary() {
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
// 生成选片只展示仍可切出不重复片段的素材(usable !== false);
|
||||
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
|
||||
const selectableMaterials = useMemo(
|
||||
() => ({
|
||||
items: materials.items.filter(isAssetUsable),
|
||||
total: materials.total,
|
||||
}),
|
||||
[materials],
|
||||
)
|
||||
|
||||
// 库内有素材但全部已用尽(用于区分空状态文案)
|
||||
const allMaterialsExhausted = materials.items.length > 0 && selectableMaterials.items.length === 0
|
||||
|
||||
return {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { smartMatchAssets } from "@/api/assets"
|
||||
import { smartMatchAssets, isAssetUsable } from "@/api/assets"
|
||||
|
||||
interface UseSmartMatchOptions {
|
||||
libraryId: string
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能素材匹配 Hook(Q5 简化版)
|
||||
* 用户不手动选素材时,一键调用后端 AI 选素材
|
||||
* 后端统一选素材逻辑后续完善,当前先走前端流程简化
|
||||
* 智能素材匹配 Hook
|
||||
* 用户不手动选素材时,一键调用后端 AI 选素材;匹配结果自动全量写入
|
||||
* smartSelectedIds(由上层持有),不向用户展示素材明细。
|
||||
*/
|
||||
export function useSmartMatch({
|
||||
libraryId,
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseSmartMatchOptions) {
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<AssetItem[]>([])
|
||||
|
||||
/* ── 一键智能匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
@@ -32,38 +29,37 @@ export function useSmartMatch({
|
||||
return
|
||||
}
|
||||
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
// 已用尽素材(usable === false)不参与智能匹配;
|
||||
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
|
||||
const usableItems = materials.items.filter(isAssetUsable)
|
||||
if (usableItems.length === 0) {
|
||||
message.warning(
|
||||
materials.items.length === 0 ? "当前视频库暂无素材" : "素材可能已用尽,请先上传新素材",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
|
||||
try {
|
||||
// 调用后端智能匹配 API
|
||||
// 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤)
|
||||
const result = await smartMatchAssets(libraryId)
|
||||
const matchedIds = result.items?.map((a: AssetItem) => a.id) ?? []
|
||||
const matched = (result.items ?? []).filter(isAssetUsable)
|
||||
const matchedIds = matched.map((a: AssetItem) => a.id)
|
||||
|
||||
if (matchedIds.length > 0) {
|
||||
onSmartSelectedIdsChange(matchedIds)
|
||||
// 保存 API 返回的完整素材列表
|
||||
const resolved = result.items?.length
|
||||
? result.items
|
||||
: materials.items.filter((a) => matchedIds.includes(a.id))
|
||||
setSmartMatchedResults(resolved)
|
||||
setHasMatched(true)
|
||||
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
|
||||
} else {
|
||||
// 后端返回空结果,回退到全选
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
// 后端返回空结果,回退到全选可用素材
|
||||
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
|
||||
setHasMatched(true)
|
||||
message.info("AI 暂未找到匹配素材,已全选当前库素材")
|
||||
}
|
||||
} catch {
|
||||
// 后端 API 尚未就绪时,回退到全选当前库素材
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
// 后端 API 尚未就绪时,回退到全选当前库可用素材
|
||||
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
|
||||
setHasMatched(true)
|
||||
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
|
||||
} finally {
|
||||
@@ -71,49 +67,15 @@ export function useSmartMatch({
|
||||
}
|
||||
}, [libraryId, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 换一批 = 重新触发智能匹配 ── */
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
// 换一批 = 重新触发智能匹配
|
||||
await handleSmartMatch()
|
||||
}, [handleSmartMatch])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
}, [materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
/* ── 计算已选素材总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(
|
||||
() =>
|
||||
materials.items
|
||||
.filter((a) => smartSelectedIds.includes(a.id))
|
||||
.reduce((sum, a) => sum + (a.duration || 0), 0),
|
||||
[materials.items, smartSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
smartMatching,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
smartMatchedResults,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function usePlanConfigLoader({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: tc.position || prev.position,
|
||||
position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
@@ -80,7 +80,7 @@ export function usePlanConfigLoader({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: cfg.title_config!.position || prev.position,
|
||||
position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useTitleCoverSync({
|
||||
...prev,
|
||||
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
||||
title: tpl.title_config!.content || prev.title,
|
||||
position: tpl.title_config!.position || prev.position,
|
||||
position: prev.position, // 强制保留默认/用户选择,不从模板同步位置
|
||||
font: tpl.title_config!.font_preset || prev.font,
|
||||
size: tpl.title_config!.font_size || prev.size,
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
|
||||
@@ -44,12 +44,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
onFailed: handleFailed,
|
||||
})
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
/* ── 生成视频 ──
|
||||
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
|
||||
const generate = useCallback(async (): Promise<boolean> => {
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
message.warning(errorMsg)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
@@ -142,7 +143,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const finalMsg = translateError(backendMsg)
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { message } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
@@ -38,13 +37,19 @@ export function useStep2Materials({
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
const {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
} = useMaterialLibrary()
|
||||
|
||||
const smartMatch = useSmartMatch({
|
||||
libraryId: selectedLibraryId,
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
materials: selectableMaterials,
|
||||
onSmartSelectedIdsChange,
|
||||
})
|
||||
|
||||
@@ -59,13 +64,19 @@ export function useStep2Materials({
|
||||
}
|
||||
if (!selectedLibraryId) return
|
||||
if (materialsLoading) return
|
||||
if (materials.items.length === 0) return
|
||||
if (selectableMaterials.items.length === 0) return
|
||||
// 防止同一视频库重复触发
|
||||
if (autoTriggeredRef.current === selectedLibraryId) return
|
||||
|
||||
autoTriggeredRef.current = selectedLibraryId
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
}, [
|
||||
selectedLibraryId,
|
||||
materialMode,
|
||||
materialsLoading,
|
||||
selectableMaterials.items,
|
||||
handleSmartMatch,
|
||||
])
|
||||
|
||||
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
|
||||
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
|
||||
@@ -169,6 +180,8 @@ export function useStep2Materials({
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
// 模式
|
||||
materialMode,
|
||||
@@ -176,19 +189,11 @@ export function useStep2Materials({
|
||||
// 手动选择
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配(简化版)
|
||||
// 智能匹配
|
||||
smartMatching: smartMatch.smartMatching,
|
||||
hasMatched: smartMatch.hasMatched,
|
||||
smartSelectedIds: smartMatch.smartSelectedIds,
|
||||
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||
handleSmartMatch: smartMatch.handleSmartMatch,
|
||||
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
|
||||
handleRefreshMatch: smartMatch.handleRefreshMatch,
|
||||
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
|
||||
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
|
||||
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
* 封面候选帧从确认生成的最终视频中获取(MediaKit 选帧)
|
||||
* 不再从预览片段创建预览视频
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import { updateEditPlan } from "@/api/template-editor"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
@@ -19,47 +19,22 @@ import {
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** 配音模式 */
|
||||
voiceMode?: "preset" | "custom" | "clone"
|
||||
/** 选中的配音素材 ID(配音素材库 asset ID) */
|
||||
selectedVoice?: string
|
||||
/** 选中的克隆音色 ID */
|
||||
selectedClonedVoice?: string
|
||||
/** BGM 开关 */
|
||||
bgm?: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
titleSettings,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
generatedVideos,
|
||||
}: UseStep6CoverProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性
|
||||
const previewParamsRef = useRef<string>("")
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
@@ -72,6 +47,9 @@ export function useStep6Cover({
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 最终成片:取第一个已完成视频 */
|
||||
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setTemplatesLoading(true)
|
||||
@@ -94,7 +72,7 @@ export function useStep6Cover({
|
||||
}
|
||||
}, [showCoverSettings, loadTemplates])
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
/** 调用后端智能封面 API,从最终成片中抽帧 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
@@ -106,19 +84,20 @@ export function useStep6Cover({
|
||||
return
|
||||
}
|
||||
|
||||
if (assetIds.length === 0) {
|
||||
message.error("请先选择素材")
|
||||
if (!finalVideo) {
|
||||
message.error("请先生成视频再选择封面")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
|
||||
const timeoutId = setTimeout(() => {
|
||||
setGenerating(false)
|
||||
}, 300000)
|
||||
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
generated_video_id: finalVideo.id,
|
||||
video_url: finalVideo.file_url || finalVideo.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
@@ -151,151 +130,9 @@ export function useStep6Cover({
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any
|
||||
const statusCode = anyErr?.response?.status
|
||||
|
||||
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errCode = anyErr?.response?.data?.code as string | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errMsg = (anyErr?.response?.data?.message ||
|
||||
anyErr?.response?.data?.detail ||
|
||||
"") as string
|
||||
const isPreviewMissing =
|
||||
statusCode === 400 &&
|
||||
(errCode?.includes("PREVIEW") ||
|
||||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
|
||||
errMsg,
|
||||
))
|
||||
|
||||
if (isPreviewMissing) {
|
||||
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
// 记录当前参数指纹,用于任务完成时校验一致性(防竞态)
|
||||
previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
|
||||
const previewVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
// 配音:始终传递 voice_library_id,确保后端能正确接收
|
||||
voice_library_id: previewVoiceLibraryId,
|
||||
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
|
||||
...(selectedVoice && !previewVoiceLibraryId ? { voice_ids: [selectedVoice] } : {}),
|
||||
// BGM 配置:受 bgm 开关控制
|
||||
bgm_config: {
|
||||
enabled: bgm !== false,
|
||||
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration)
|
||||
const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
if (previewResp.task_id && previewParamsRef.current === currentFingerprint) {
|
||||
onPreviewTaskCreated?.(previewResp.task_id)
|
||||
// 提取后端自动关联的 source_edit_plan_id,供 fallback 路径使用
|
||||
if (previewResp.source_edit_plan_id) {
|
||||
onSourceEditPlanIdExtracted?.(previewResp.source_edit_plan_id)
|
||||
}
|
||||
}
|
||||
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let finished = false
|
||||
const done = (fn: () => void) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timeoutId)
|
||||
fn()
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
done(() => reject(new Error("预览生成超时,请稍后重试")))
|
||||
}, 120_000)
|
||||
const poll = async () => {
|
||||
if (finished) return
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
// 保存预览视频地址到 plan.config.rendered_storage_key,
|
||||
// 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
|
||||
// video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
|
||||
if (status.video_url) {
|
||||
try {
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: status.video_url },
|
||||
})
|
||||
} catch (saveErr) {
|
||||
console.warn(
|
||||
"[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
|
||||
saveErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
done(() => resolve())
|
||||
} else if (status.status === "failed") {
|
||||
done(() => reject(new Error(status.error_message || "预览渲染失败")))
|
||||
} else {
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
done(() => reject(e))
|
||||
}
|
||||
}
|
||||
poll()
|
||||
})
|
||||
message.success("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
const retryResp = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const retryUrl = retryResp.cover?.image_url || ""
|
||||
if (retryUrl) {
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: retryUrl,
|
||||
ai_suggested_time: retryResp.cover?.frame_time ?? null,
|
||||
})
|
||||
message.success("封面生成成功")
|
||||
} else {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (retryErr) {
|
||||
console.error("[Step6] 自动创建预览后重试失败:", retryErr)
|
||||
message.error("预览视频创建失败,请稍后重试")
|
||||
}
|
||||
} else if (anyErr?.__msgShown) {
|
||||
if (anyErr?.__msgShown) {
|
||||
// 拦截器已处理,不再重复弹出
|
||||
} else {
|
||||
let errorMsg = "封面生成失败"
|
||||
@@ -310,31 +147,21 @@ export function useStep6Cover({
|
||||
console.error("[Step6] 后端返回:", e.response.data)
|
||||
} else if (e.request) {
|
||||
errorMsg = "服务器无响应,请检查网络连接"
|
||||
console.error("[Step6] 请求无响应:", e.request)
|
||||
} else if (e.message) {
|
||||
errorMsg = e.message
|
||||
}
|
||||
message.error(errorMsg)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
assetIds,
|
||||
finalVideo,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
generating,
|
||||
duration,
|
||||
titleSettings,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
@@ -391,13 +218,11 @@ export function useStep6Cover({
|
||||
const selectedTemplateName =
|
||||
coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认"
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
finalVideo,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
|
||||
@@ -87,7 +87,9 @@ export function useStep7Generate({
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
const el =
|
||||
document.querySelector(".xx-inline-video-player") ||
|
||||
document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* 前端实时预览架构:Step5 无需等待服务器渲染
|
||||
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -16,6 +13,10 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 预览是否已就绪(素材已加载,可播放) */
|
||||
previewReady: boolean
|
||||
/** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
|
||||
generated: boolean
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -32,6 +33,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
generated,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
@@ -47,11 +50,23 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
// Step4(标题+预览):标题必填 + 预览必须已加载
|
||||
if (currentStep === 4) {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览视频正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Step5(确认生成):必须已完成生成才能进入封面
|
||||
if (currentStep === 5 && !generated) {
|
||||
message.warning("请先生成视频")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
if (currentStep < 6) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
@@ -318,6 +319,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
audioUrl={ttsAudioUrl ?? ""}
|
||||
error={ttsError ?? ""}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface TtsPresetVoice {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 克隆音色下拉选项(仅 ready) */
|
||||
export interface TtsClonedVoice {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TtsModalProps {
|
||||
open: boolean
|
||||
text: string
|
||||
@@ -18,6 +24,8 @@ interface TtsModalProps {
|
||||
audioUrl: string
|
||||
error: string
|
||||
presetVoices: TtsPresetVoice[]
|
||||
/** 可用克隆音色(仅 ready),为空时不显示该分组 */
|
||||
clonedVoices?: TtsClonedVoice[]
|
||||
onClose: () => void
|
||||
onTextChange: (value: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
@@ -35,6 +43,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
audioUrl,
|
||||
error,
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
onClose: _onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
@@ -101,11 +110,22 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="预置音色">
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{clonedVoices.length > 0 && (
|
||||
<optgroup label="我的克隆音色">
|
||||
{clonedVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ const CardPlayer: React.FC<CardPlayerProps> = ({
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
|
||||
const progress = isPlaying && duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="vmat-card-player">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
|
||||
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/**
|
||||
* TTS 合成 Hook
|
||||
@@ -31,6 +32,17 @@ export function useTtsSynthesize() {
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
|
||||
|
||||
// 我的克隆音色(仅 ready 可用于合成;voice_id 直接传 profile UUID,后端解析)
|
||||
const { data: clonedData } = useQuery({
|
||||
queryKey: ["voice-clones"],
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const clonedVoices = (clonedData?.items ?? [])
|
||||
.map((p) => toVoiceClone(p))
|
||||
.filter((c) => c.status === "ready")
|
||||
.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
@@ -124,6 +136,7 @@ export function useTtsSynthesize() {
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
|
||||
+17
-5
@@ -48,20 +48,32 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度,后端自动创建 ingest job)
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
const complete = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 轮询 ingest job 状态
|
||||
// 去重命中(同库已存在相同 file_hash 素材):
|
||||
// 后端返回 duplicated=true,ingest_job_id 为空;跳过轮询和打标签,
|
||||
// mutationFn 正常 return 即 resolve,useMutation 自动触发 onSuccess 刷新列表。
|
||||
// 已存在素材复用上次标签,无需重新打标。
|
||||
if (complete.duplicated === true) {
|
||||
return
|
||||
}
|
||||
if (!complete.ingest_job_id) {
|
||||
throw new Error("上传完成但未返回处理任务 ID,请重试")
|
||||
}
|
||||
const { ingest_job_id } = complete
|
||||
|
||||
// 3. 轮询 ingest job 状态(complete 后先立即查一次,未完成再每 5s 轮询)
|
||||
let job: Awaited<ReturnType<typeof getIngestJob>> | null = null
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟
|
||||
while (retries < maxRetries) {
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
while (job.status !== "completed" && job.status !== "failed" && retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
if (job.status === "completed" || job.status === "failed") break
|
||||
retries++
|
||||
}
|
||||
|
||||
@@ -72,7 +84,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
|
||||
// 4. 打标签(标签走独立 API)
|
||||
// 4. 打标签(标签走独立 API;去重命中时已提前 return,这里只对新创建的素材执行)
|
||||
if (data.tagIds.length > 0 && job.result_asset_id) {
|
||||
await tagAsset(job.result_asset_id, data.tagIds)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useAudioPlayer } from "./hooks/useAudioPlayer"
|
||||
import { useCloneOperations } from "./hooks/useCloneOperations"
|
||||
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
|
||||
import { useVoiceUpload } from "./hooks/useVoiceUpload"
|
||||
import { useMaterialDelete } from "./hooks/useMaterialDelete"
|
||||
import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete"
|
||||
import "./voices.css"
|
||||
|
||||
let toastIdSeq = 0
|
||||
@@ -70,17 +72,39 @@ const VoiceLibrary: React.FC = () => {
|
||||
materialCount,
|
||||
} = useVoicesData()
|
||||
|
||||
// ── 播放控制 ──────────────────────────────────────────
|
||||
// ── 播放控制(三 tab 共用:真实 Audio 播放 + TTS 试听) ──
|
||||
const {
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
duration: playDuration,
|
||||
handlePause,
|
||||
handleToggleVoice,
|
||||
handleToggleMaterial,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
} = useAudioPlayer()
|
||||
|
||||
// ── 配音素材删除 / 批量删除(删除正在播放的素材时停止播放) ──
|
||||
const { handleMaterialDelete } = useMaterialDelete({
|
||||
materials: materials as AssetItem[],
|
||||
stopPlayback,
|
||||
showToast,
|
||||
})
|
||||
const {
|
||||
selectedIds: materialSelectedIds,
|
||||
selectedCount: materialSelectedCount,
|
||||
allSelected: materialAllSelected,
|
||||
batchDeleting: materialBatchDeleting,
|
||||
toggleSelect: toggleMaterialSelect,
|
||||
toggleSelectAll: toggleMaterialSelectAll,
|
||||
handleBatchDelete: handleMaterialBatchDelete,
|
||||
} = useMaterialBatchDelete({
|
||||
materials: materials as AssetItem[],
|
||||
stopPlayback,
|
||||
showToast,
|
||||
})
|
||||
|
||||
// ── 克隆音色操作 ──────────────────────────────────────
|
||||
const {
|
||||
detailVoice,
|
||||
@@ -110,7 +134,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
} = useTtsSynthesize({ presetVoices, showToast })
|
||||
} = useTtsSynthesize({
|
||||
presetVoices,
|
||||
clonedVoices: clonedVoices
|
||||
.filter((v) => v.status === "ready")
|
||||
.map((v) => ({ id: v.id, name: v.name })),
|
||||
showToast,
|
||||
})
|
||||
|
||||
// ── 上传音频 ──────────────────────────────────────────
|
||||
const {
|
||||
@@ -203,8 +233,10 @@ const VoiceLibrary: React.FC = () => {
|
||||
loading={presetLoading}
|
||||
voices={filteredPreset}
|
||||
playingId={playingId}
|
||||
loadingId={loadingId}
|
||||
currentTime={currentTime}
|
||||
onPlay={handlePlay}
|
||||
playDuration={playDuration}
|
||||
onToggle={handleToggleVoice}
|
||||
onPause={handlePause}
|
||||
onSeek={handleSeek}
|
||||
onClearFilters={handleClearFilters}
|
||||
@@ -217,9 +249,12 @@ const VoiceLibrary: React.FC = () => {
|
||||
loading={cloneLoading}
|
||||
voices={clonedVoices}
|
||||
playingId={playingId}
|
||||
loadingId={loadingId}
|
||||
currentTime={currentTime}
|
||||
onPlay={handleTogglePlay}
|
||||
playDuration={playDuration}
|
||||
onToggle={handleToggleVoice}
|
||||
onPause={handlePause}
|
||||
onSeek={handleSeek}
|
||||
onUse={handleCloneUse}
|
||||
onDelete={handleCloneDelete}
|
||||
onRetry={handleCloneRetry}
|
||||
@@ -234,6 +269,19 @@ const VoiceLibrary: React.FC = () => {
|
||||
loading={materialLoading}
|
||||
materials={materials as AssetItem[]}
|
||||
onOpenUpload={() => setUploadOpen(true)}
|
||||
onDelete={handleMaterialDelete}
|
||||
selectedIds={materialSelectedIds}
|
||||
selectedCount={materialSelectedCount}
|
||||
allSelected={materialAllSelected}
|
||||
batchDeleting={materialBatchDeleting}
|
||||
onToggleSelect={toggleMaterialSelect}
|
||||
onToggleSelectAll={toggleMaterialSelectAll}
|
||||
onBatchDelete={handleMaterialBatchDelete}
|
||||
playingId={playingId}
|
||||
currentTime={currentTime}
|
||||
playDuration={playDuration}
|
||||
onTogglePlay={handleToggleMaterial}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -266,6 +314,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices
|
||||
.filter((v) => v.status === "ready")
|
||||
.map((v) => ({ id: v.id, name: v.name }))}
|
||||
onTtsClose={handleTtsClose}
|
||||
onTtsTextChange={setTtsText}
|
||||
onTtsVoiceChange={setTtsVoiceId}
|
||||
|
||||
@@ -8,9 +8,12 @@ import CardFooter from "./clone-voice-card/CardFooter"
|
||||
export interface CloneVoiceCardProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
currentTime: number
|
||||
playDuration?: number
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek?: (time: number) => void
|
||||
onUse: () => void
|
||||
onDelete: () => void
|
||||
onRetry: () => void
|
||||
|
||||
@@ -12,9 +12,12 @@ export interface ClonedVoiceTabProps {
|
||||
loading: boolean
|
||||
voices: ClonedVoiceDisplay[]
|
||||
playingId: string | null
|
||||
loadingId: string | null
|
||||
currentTime: number
|
||||
onPlay: (voiceId: string, duration: number) => void
|
||||
playDuration: number
|
||||
onToggle: (voiceId: string) => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onUse: (voice: ClonedVoiceDisplay) => void
|
||||
onDelete: (voice: ClonedVoiceDisplay) => void
|
||||
onRetry: (voice: ClonedVoiceDisplay) => void
|
||||
@@ -26,9 +29,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
|
||||
loading,
|
||||
voices,
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
onPlay,
|
||||
playDuration,
|
||||
onToggle,
|
||||
onPause,
|
||||
onSeek,
|
||||
onUse,
|
||||
onDelete,
|
||||
onRetry,
|
||||
@@ -54,9 +60,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
isPlaying={playingId === voice.id}
|
||||
isLoading={loadingId === voice.id}
|
||||
currentTime={playingId === voice.id ? currentTime : 0}
|
||||
onPlay={() => onPlay(voice.id, voice.duration)}
|
||||
playDuration={playingId === voice.id ? playDuration : voice.duration}
|
||||
onPlay={() => onToggle(voice.voiceId)}
|
||||
onPause={onPause}
|
||||
onSeek={onSeek}
|
||||
onUse={() => onUse(voice)}
|
||||
onDelete={() => onDelete(voice)}
|
||||
onRetry={() => onRetry(voice)}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { type AssetItem } from "@/api/assets"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface MaterialVoiceCardProps {
|
||||
asset: AssetItem
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/** 配音素材卡片 */
|
||||
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
|
||||
return (
|
||||
<div className="vmat-card" onClick={onClick}>
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialVoiceCard
|
||||
@@ -1,31 +1,126 @@
|
||||
/**
|
||||
* VoiceLibrary 配音素材 Tab 内容
|
||||
*
|
||||
* 卡片视觉与预置音色卡片(xx-voice-card)一致:白底圆角横向布局、
|
||||
* 左侧圆形图标、名称/副标题、波形条、底部播放控件。
|
||||
* 删除按钮常驻右上角;支持全选/多选 + 批量删除。
|
||||
* 播放状态由页面级统一 hook(VoiceLibrary/useAudioPlayer)下发。
|
||||
*/
|
||||
import React from "react"
|
||||
import { SoundOutlined, UploadOutlined, AudioOutlined } from "@ant-design/icons"
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
UploadOutlined,
|
||||
AudioOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { mapAssetToMaterial, type VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
import { formatTime, formatFileSize } from "../utils/format"
|
||||
|
||||
export interface MaterialVoiceTabProps {
|
||||
loading: boolean
|
||||
materials: AssetItem[]
|
||||
onOpenUpload: () => void
|
||||
onDelete: (asset: AssetItem) => void
|
||||
// 批量删除
|
||||
selectedIds: Set<string>
|
||||
selectedCount: number
|
||||
allSelected: boolean
|
||||
batchDeleting: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onToggleSelectAll: () => void
|
||||
onBatchDelete: () => void
|
||||
// 播放控制(页面级统一 hook)
|
||||
playingId: string | null
|
||||
currentTime: number
|
||||
playDuration: number
|
||||
onTogglePlay: (material: VoiceMaterial) => void
|
||||
onSeek: (time: number) => void
|
||||
}
|
||||
|
||||
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
loading,
|
||||
materials,
|
||||
onOpenUpload,
|
||||
onDelete,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
allSelected,
|
||||
batchDeleting,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onBatchDelete,
|
||||
playingId,
|
||||
currentTime,
|
||||
playDuration,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
}) => {
|
||||
const progressRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
/** 播放中点击进度条 seek;非播放态点击则开始播放 */
|
||||
const handleProgressClick =
|
||||
(asset: AssetItem, material: VoiceMaterial, dur: number) =>
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!asset.file_url) return
|
||||
if (playingId !== asset.id) {
|
||||
onTogglePlay(material)
|
||||
return
|
||||
}
|
||||
const el = progressRefs.current[asset.id]
|
||||
if (!el || dur <= 0) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * dur)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 批量操作栏 */}
|
||||
{!loading && materials.length > 0 && (
|
||||
<div className="vmat-batch-bar">
|
||||
<div className="vmat-batch-bar-left">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
|
||||
onClick={onToggleSelectAll}
|
||||
aria-label={allSelected ? "取消全选" : "全选"}
|
||||
>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</button>
|
||||
<button type="button" className="vmat-select-all" onClick={onToggleSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</button>
|
||||
{selectedCount > 0 && (
|
||||
<span className="vmat-batch-count">已选择 {selectedCount} 项</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="vmat-batch-bar-right">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedCount === 0 || batchDeleting}
|
||||
onClick={onBatchDelete}
|
||||
>
|
||||
{batchDeleting ? "删除中..." : "批量删除"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 骨架屏加载 */}
|
||||
{loading && (
|
||||
<div className="xx-voice-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="vmat-card vmat-card--skeleton">
|
||||
<div className="vmat-thumb" />
|
||||
<div className="vmat-info">
|
||||
<div key={i} className="xx-voice-card vmat-card--skeleton">
|
||||
<div className="vmat-skeleton-avatar" />
|
||||
<div className="xx-voice-info">
|
||||
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
||||
<div className="vmat-skeleton-line" />
|
||||
</div>
|
||||
@@ -38,27 +133,96 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
{!loading && materials.length > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{materials.map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
const material = mapAssetToMaterial(asset)
|
||||
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
|
||||
const cardDuration = asset.duration || material.duration || 0
|
||||
const isPlaying = playingId === asset.id
|
||||
const isSelected = selectedIds.has(asset.id)
|
||||
// 播放中以 audio 真实时长为准,未播放显示卡片时长
|
||||
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
|
||||
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
|
||||
const progress =
|
||||
isPlaying && effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
|
||||
return (
|
||||
<div key={asset.id} className="vmat-card">
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`xx-voice-card vmat-card${isSelected ? " selected" : ""}${
|
||||
isPlaying ? " playing" : ""
|
||||
}`}
|
||||
>
|
||||
{/* 左上角批量选择 checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(asset.id)
|
||||
}}
|
||||
aria-label={isSelected ? "取消选择" : "选择素材"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 右上角删除按钮(常驻可见) */}
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(asset)
|
||||
}}
|
||||
title="删除"
|
||||
aria-label="删除配音素材"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
|
||||
<div className="xx-voice-avatar vmat-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
|
||||
<div className="xx-voice-info vmat-info">
|
||||
<div className="xx-voice-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>
|
||||
{asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
|
||||
</span>
|
||||
<div className="xx-voice-subtitle">
|
||||
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
|
||||
{" · "}
|
||||
{cardDuration > 0 ? formatTime(cardDuration) : "--:--"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 波形装饰条(与预置音色卡片一致) */}
|
||||
<div className="xx-voice-wave" />
|
||||
|
||||
{/* 播放控制区:真实音频播放 */}
|
||||
<div className="xx-voice-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-voice-play-btn"
|
||||
disabled={!asset.file_url}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTogglePlay(material)
|
||||
}}
|
||||
title={asset.file_url ? (isPlaying ? "暂停" : "试听") : "暂无可播放音频"}
|
||||
aria-label={isPlaying ? "暂停播放" : "播放音频"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div
|
||||
ref={(el) => {
|
||||
progressRefs.current[asset.id] = el
|
||||
}}
|
||||
className="xx-voice-progress"
|
||||
onClick={handleProgressClick(asset, material, effectiveDuration)}
|
||||
>
|
||||
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{isPlaying ? formatTime(currentTime) : formatTime(cardDuration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -19,10 +19,12 @@ export interface PresetVoiceTabProps {
|
||||
loading: boolean
|
||||
voices: PresetVoiceDisplay[]
|
||||
playingId: string | null
|
||||
loadingId: string | null
|
||||
currentTime: number
|
||||
onPlay: (id: string, duration: number) => void
|
||||
playDuration: number
|
||||
onToggle: (voiceId: string) => void
|
||||
onPause: () => void
|
||||
onSeek: (id: string, time: number, duration: number) => void
|
||||
onSeek: (time: number) => void
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
@@ -36,8 +38,10 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
|
||||
loading,
|
||||
voices,
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
onPlay,
|
||||
playDuration,
|
||||
onToggle,
|
||||
onPause,
|
||||
onSeek,
|
||||
onClearFilters,
|
||||
@@ -71,15 +75,16 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
|
||||
name={voice.name}
|
||||
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
|
||||
tags={voice.tags}
|
||||
duration={voice.duration}
|
||||
duration={playingId === voice.id ? playDuration : voice.duration}
|
||||
gender={voice.gender}
|
||||
isPlaying={playingId === voice.id}
|
||||
isLoading={loadingId === voice.id}
|
||||
isSelected={false}
|
||||
currentTime={playingId === voice.id ? currentTime : 0}
|
||||
starred={voice.starred}
|
||||
onPlay={() => onPlay(voice.id, voice.duration)}
|
||||
onPlay={() => onToggle(voice.voiceId)}
|
||||
onPause={onPause}
|
||||
onSeek={(time) => onSeek(voice.id, time, voice.duration)}
|
||||
onSeek={(time) => onSeek(time)}
|
||||
onToggleStar={() => {}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
@@ -36,7 +37,12 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<TextInputSection value={ttsText} onChange={onTextChange} />
|
||||
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
|
||||
<VoiceSelector
|
||||
value={ttsVoiceId}
|
||||
onChange={onVoiceChange}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
/>
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
HeartOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import { genderClass, formatTime } from "@/pages/voices/utils/format"
|
||||
@@ -16,6 +17,7 @@ export interface VoiceCardProps {
|
||||
duration: number
|
||||
gender: VoiceGender
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
isSelected: boolean
|
||||
currentTime: number
|
||||
starred?: boolean
|
||||
@@ -36,6 +38,7 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
duration,
|
||||
gender,
|
||||
isPlaying,
|
||||
isLoading = false,
|
||||
isSelected,
|
||||
currentTime,
|
||||
starred,
|
||||
@@ -51,8 +54,12 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || status !== "ready") return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
onSeek(percent * duration)
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
if (isPlaying && duration > 0) {
|
||||
onSeek(percent * duration)
|
||||
} else if (!isPlaying && !isLoading) {
|
||||
onPlay()
|
||||
}
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
@@ -110,19 +117,27 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
<div className="xx-voice-controls">
|
||||
<button
|
||||
className="xx-voice-play-btn"
|
||||
disabled={isLoading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
aria-label={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
{isLoading ? (
|
||||
<LoadingOutlined spin />
|
||||
) : isPlaying ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)}
|
||||
</button>
|
||||
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
|
||||
{isPlaying ? formatTime(currentTime) : duration > 0 ? formatTime(duration) : "试听"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import CloneDetailModal from "./CloneDetailModal"
|
||||
import UploadVoiceModal from "./UploadVoiceModal"
|
||||
@@ -45,6 +46,8 @@ export interface VoiceModalsProps {
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready) */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
onTtsClose: () => void
|
||||
onTtsTextChange: (text: string) => void
|
||||
onTtsVoiceChange: (id: string) => void
|
||||
@@ -81,6 +84,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
onTtsClose,
|
||||
onTtsTextChange,
|
||||
onTtsVoiceChange,
|
||||
@@ -129,6 +133,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
onClose={onTtsClose}
|
||||
onTextChange={onTtsTextChange}
|
||||
onVoiceChange={onTtsVoiceChange}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
|
||||
interface CardFooterProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
currentTime: number
|
||||
playDuration?: number
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek?: (time: number) => void
|
||||
onUse: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
@@ -15,14 +23,30 @@ interface CardFooterProps {
|
||||
const CardFooter: React.FC<CardFooterProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
isLoading = false,
|
||||
currentTime,
|
||||
playDuration,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onUse,
|
||||
onRetry,
|
||||
}) => {
|
||||
const isFailed = voice.status === "failed"
|
||||
const isProcessing = voice.status === "processing"
|
||||
// 播放中以 audio 真实时长为准
|
||||
const effectiveDuration = isPlaying ? playDuration || voice.duration : voice.duration
|
||||
|
||||
/** 播放中点击进度条 seek;非播放态点击触发播放 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
if (isPlaying && effectiveDuration > 0) {
|
||||
onSeek?.(percent * effectiveDuration)
|
||||
} else if (!isLoading) {
|
||||
onPlay()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-clone-footer">
|
||||
@@ -31,21 +55,30 @@ const CardFooter: React.FC<CardFooterProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
disabled={isLoading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
aria-label={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
{isLoading ? (
|
||||
<LoadingOutlined spin />
|
||||
) : isPlaying ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)}
|
||||
</button>
|
||||
<div className="xx-clone-progress">
|
||||
<div className="xx-clone-progress" onClick={handleProgressClick}>
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{
|
||||
width: isPlaying
|
||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||
: "0%",
|
||||
width:
|
||||
isPlaying && effectiveDuration > 0
|
||||
? `${Math.min((currentTime / effectiveDuration) * 100, 100)}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,28 @@ import React from "react"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
/** 克隆音色下拉选项(最小结构,新旧页面各自映射) */
|
||||
export interface TtsClonedVoiceOption {
|
||||
/** 克隆音色 profile id(合成时直接作为 voice_id 传后端解析) */
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string
|
||||
onChange: (voiceId: string) => void
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅克隆完成/ready),为空时不显示「我的克隆音色」分组 */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
}
|
||||
|
||||
/** 音色选择下拉 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
|
||||
/** 音色选择下拉:预置音色 + 我的克隆音色 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -36,11 +50,22 @@ const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVo
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="预置音色">
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{clonedVoices.length > 0 && (
|
||||
<optgroup label="我的克隆音色">
|
||||
{clonedVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import type { TtsClonedVoiceOption } from "./VoiceSelector"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface TtsModalProps {
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready),为空时下拉不显示该分组 */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
|
||||
@@ -1,102 +1,200 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
|
||||
/**
|
||||
* 音频播放控制 Hook
|
||||
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
|
||||
* (适用于预置音色/克隆音色卡片的播放按钮交互)
|
||||
* 配音库真实音频播放控制 Hook(预置音色 / 克隆音色 / 配音素材三个 tab 共用)
|
||||
*
|
||||
* - 配音素材:直接播放 asset.file_url(用户上传的真实音频)
|
||||
* - 预置/克隆音色:调 POST /tts/preview 合成本示例文案,拿到 audio_url 后真实播放,
|
||||
* 合成结果按 voiceId 内存缓存,同一音色二次试听不重复合成
|
||||
* - 全库同一时刻只有一个 Audio 在响:切卡片 / 切 tab / 离开页面自动停止
|
||||
* - timeupdate 驱动进度条,loadedmetadata 取真实时长,ended 自动复位
|
||||
*
|
||||
* 注意:合成失败的错误提示由 apiClient 拦截器统一 toast(含后端
|
||||
* 「音色克隆尚未完成,请稍后再试」文案),hook 内不重复提示。
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import type { VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
|
||||
/** 卡片试听统一示例文案 */
|
||||
export const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。"
|
||||
|
||||
interface PreviewCacheEntry {
|
||||
url: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null)
|
||||
|
||||
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
|
||||
const handlePlay = useCallback(
|
||||
(voiceId: string, duration: number, startTime: number = 0) => {
|
||||
if (playingId === voiceId) return
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
setCurrentTime(startTime)
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
return 0
|
||||
}
|
||||
return prev + 0.1
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const pausedRef = useRef<{ id: string; url: string; duration: number } | null>(null)
|
||||
const previewCacheRef = useRef<Map<string, PreviewCacheEntry>>(new Map())
|
||||
/** 试听合成请求序号:旧请求返回时丢弃,防止竞态 */
|
||||
const reqSeqRef = useRef(0)
|
||||
|
||||
/** 暂停播放 */
|
||||
const handlePause = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
/** 停止当前播放并复位状态 */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
pausedRef.current = null
|
||||
setPlayingId(null)
|
||||
setLoadingId(null)
|
||||
setCurrentTime(0)
|
||||
setDuration(0)
|
||||
}, [])
|
||||
|
||||
/** 跳转到指定时间 */
|
||||
const handleSeek = useCallback(
|
||||
(voiceId: string, time: number, duration: number) => {
|
||||
if (playingId !== voiceId) {
|
||||
// 不同音色:从指定时间开始播放
|
||||
handlePlay(voiceId, duration, time)
|
||||
} else {
|
||||
// 同一音色:直接跳转
|
||||
setCurrentTime(time)
|
||||
/** 用指定 URL 创建 Audio 并播放 */
|
||||
const startAudio = useCallback((id: string, url: string, knownDuration?: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
setCurrentTime(audio.currentTime)
|
||||
})
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||
setDuration(audio.duration)
|
||||
}
|
||||
})
|
||||
audio.addEventListener("ended", () => {
|
||||
if (audioRef.current === audio) audioRef.current = null
|
||||
pausedRef.current = null
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
})
|
||||
|
||||
if (knownDuration && knownDuration > 0) setDuration(knownDuration)
|
||||
setCurrentTime(0)
|
||||
setPlayingId(id)
|
||||
pausedRef.current = null
|
||||
|
||||
audio.play().catch(() => {
|
||||
// 自动播放被拦截或 URL 失效:复位按钮,错误提示由拦截器/环境处理
|
||||
if (audioRef.current === audio) audioRef.current = null
|
||||
setPlayingId(null)
|
||||
setLoadingId((cur) => (cur === id ? null : cur))
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** 播放配音素材(file_url 直链) */
|
||||
const playMaterial = useCallback(
|
||||
(material: VoiceMaterial) => {
|
||||
if (!material.fileUrl) return
|
||||
startAudio(material.id, material.fileUrl, material.duration)
|
||||
},
|
||||
[playingId, handlePlay],
|
||||
[startAudio],
|
||||
)
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const handleTogglePlay = useCallback(
|
||||
(voiceId: string, duration: number) => {
|
||||
/** 预置/克隆音色试听:先 TTS 合成(带缓存),再真实播放 */
|
||||
const playVoice = useCallback(
|
||||
async (voiceId: string) => {
|
||||
// 暂停中恢复
|
||||
if (pausedRef.current?.id === voiceId && audioRef.current) {
|
||||
try {
|
||||
await audioRef.current.play()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
pausedRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const cached = previewCacheRef.current.get(voiceId)
|
||||
if (cached) {
|
||||
startAudio(voiceId, cached.url, cached.duration)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++reqSeqRef.current
|
||||
setLoadingId(voiceId)
|
||||
try {
|
||||
const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: voiceId, speed: 1.0 })
|
||||
if (seq !== reqSeqRef.current) return // 已被更新的请求取代
|
||||
previewCacheRef.current.set(voiceId, { url: res.audio_url, duration: res.duration })
|
||||
setLoadingId(null)
|
||||
startAudio(voiceId, res.audio_url, res.duration)
|
||||
} catch {
|
||||
if (seq !== reqSeqRef.current) return
|
||||
// 错误文案(含「克隆尚未完成」)由 apiClient 拦截器统一 toast
|
||||
setLoadingId(null)
|
||||
}
|
||||
},
|
||||
[startAudio],
|
||||
)
|
||||
|
||||
/** 暂停(记录暂停对象,供再次点击恢复) */
|
||||
const handlePause = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.pause()
|
||||
pausedRef.current = { id: playingId ?? "", url: audio.src, duration }
|
||||
setPlayingId(null)
|
||||
}, [playingId, duration])
|
||||
|
||||
/** 音色卡片播放/暂停切换 */
|
||||
const handleToggleVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
if (playingId === voiceId) {
|
||||
handlePause()
|
||||
} else {
|
||||
handlePlay(voiceId, duration)
|
||||
stopPlayback()
|
||||
void playVoice(voiceId)
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay, handlePause],
|
||||
[playingId, handlePause, stopPlayback, playVoice],
|
||||
)
|
||||
|
||||
/** 停止所有播放(切换 Tab 时调用) */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
/** 素材卡片播放/暂停切换 */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(material: VoiceMaterial) => {
|
||||
if (playingId === material.id) {
|
||||
handlePause()
|
||||
} else {
|
||||
stopPlayback()
|
||||
playMaterial(material)
|
||||
}
|
||||
},
|
||||
[playingId, handlePause, stopPlayback, playMaterial],
|
||||
)
|
||||
|
||||
/** 进度条 seek(仅播放中有效;非播放态点击进度条由卡片改为触发播放) */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.currentTime = time
|
||||
setCurrentTime(time)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理
|
||||
// 组件卸载时清理音频
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
duration,
|
||||
playMaterial,
|
||||
playVoice,
|
||||
handlePause,
|
||||
handleToggleVoice,
|
||||
handleToggleMaterial,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 配音库「配音素材」Tab 批量删除逻辑
|
||||
*
|
||||
* 选择状态本地维护;批量删除循环调 deleteAsset(无批量接口),
|
||||
* 单个失败不中断;删除项含正在播放的素材时停止播放。
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Modal } from "@/components/ui"
|
||||
import { deleteAsset, type AssetItem } from "@/api/assets"
|
||||
import type { Toast } from "../components/VoiceToasts"
|
||||
|
||||
interface UseMaterialBatchDeleteOptions {
|
||||
materials: AssetItem[]
|
||||
/** 停止播放回调(删除正在播放的素材时调用) */
|
||||
stopPlayback: () => void
|
||||
showToast: (message: string, type: Toast["type"]) => void
|
||||
}
|
||||
|
||||
export function useMaterialBatchDelete({
|
||||
materials,
|
||||
stopPlayback,
|
||||
showToast,
|
||||
}: UseMaterialBatchDeleteOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [batchDeleting, setBatchDeleting] = useState(false)
|
||||
|
||||
/** 只统计当前列表中仍然存在的选中项(删除后自动收敛) */
|
||||
const validSelected = useMemo(
|
||||
() => materials.filter((m) => selectedIds.has(m.id)),
|
||||
[materials, selectedIds],
|
||||
)
|
||||
const selectedCount = validSelected.length
|
||||
const allSelected = materials.length > 0 && selectedCount === materials.length
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))
|
||||
}, [allSelected, materials])
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
const handleBatchDelete = useCallback(() => {
|
||||
const targets = validSelected
|
||||
if (targets.length === 0) return
|
||||
Modal.confirm({
|
||||
title: "确认批量删除",
|
||||
content: `确定删除选中的 ${targets.length} 个素材?删除后不可恢复。`,
|
||||
okText: "删除",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
setBatchDeleting(true)
|
||||
let successCount = 0
|
||||
for (const asset of targets) {
|
||||
try {
|
||||
await deleteAsset(asset.id)
|
||||
successCount++
|
||||
} catch {
|
||||
/* 单个失败不中断 */
|
||||
}
|
||||
}
|
||||
// 删除项含正在播放的素材(播放 id 与素材 id 一致)→ 停止播放
|
||||
stopPlayback()
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
setSelectedIds(new Set())
|
||||
setBatchDeleting(false)
|
||||
if (successCount === targets.length) {
|
||||
showToast(`已批量删除 ${successCount} 个素材`, "success")
|
||||
} else {
|
||||
showToast(`已批量删除 ${successCount}/${targets.length} 个素材,部分失败`, "error")
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [validSelected, stopPlayback, queryClient, showToast])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
allSelected,
|
||||
batchDeleting,
|
||||
toggleSelect,
|
||||
toggleSelectAll,
|
||||
clearSelection,
|
||||
handleBatchDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 配音库「配音素材」Tab 单个删除逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Modal } from "@/components/ui"
|
||||
import { deleteAsset, type AssetItem } from "@/api/assets"
|
||||
import type { Toast } from "../components/VoiceToasts"
|
||||
|
||||
interface UseMaterialDeleteOptions {
|
||||
materials: AssetItem[]
|
||||
/** 停止播放回调(删除正在播放的素材时调用) */
|
||||
stopPlayback: () => void
|
||||
showToast: (message: string, type: Toast["type"]) => void
|
||||
}
|
||||
|
||||
export function useMaterialDelete({
|
||||
materials,
|
||||
stopPlayback,
|
||||
showToast,
|
||||
}: UseMaterialDeleteOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (assetId: string) => deleteAsset(assetId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
// 删除成功后停止播放(若删的是正在播放的素材,播放 id 与素材 id 一致)
|
||||
stopPlayback()
|
||||
showToast("素材已删除", "success")
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "未知错误"
|
||||
showToast(`删除失败:${msg}`, "error")
|
||||
},
|
||||
})
|
||||
|
||||
const handleMaterialDelete = useCallback(
|
||||
(asset: AssetItem) => {
|
||||
const material = materials.find((m) => m.id === asset.id)
|
||||
if (!material) return
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: `确定删除配音素材「${material.name}」吗?删除后不可恢复。`,
|
||||
okText: "删除",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
onOk: () => deleteMutation.mutate(material.id),
|
||||
})
|
||||
},
|
||||
[materials, deleteMutation],
|
||||
)
|
||||
|
||||
return { handleMaterialDelete, isDeleting: deleteMutation.isPending }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { type PresetVoiceDisplay } from "../types"
|
||||
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -12,10 +13,16 @@ export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
*/
|
||||
interface UseTtsSynthesizeProps {
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready;合成时 voice_id 直接传克隆 profile UUID,后端解析) */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
|
||||
export function useTtsSynthesize({
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
showToast,
|
||||
}: UseTtsSynthesizeProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [ttsOpen, setTtsOpen] = useState(false)
|
||||
@@ -131,6 +138,7 @@ export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizePr
|
||||
ttsError,
|
||||
// 可选音色列表
|
||||
ttsPresetVoices: presetVoices,
|
||||
ttsClonedVoices: clonedVoices,
|
||||
// Setters
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
|
||||
@@ -32,24 +32,39 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件(后端会自动创建 ingest job) */
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
const complete = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 轮询 ingest job 状态,等待 Worker 处理完成 */
|
||||
let jobStatus = ""
|
||||
/* 去重命中(同库已存在相同 file_hash 素材):
|
||||
* 后端返回 duplicated=true,ingest_job_id 为空,
|
||||
* 不轮询、直接按上传成功处理(onSuccess 分支 toast + 刷新列表)。
|
||||
* 注意:mutationFn 正常 return 即视为 resolve,useMutation 会自动调 onSuccess。
|
||||
*/
|
||||
if (complete.duplicated === true) {
|
||||
return
|
||||
}
|
||||
if (!complete.ingest_job_id) {
|
||||
throw new Error("上传完成但未返回处理任务 ID,请重试")
|
||||
}
|
||||
const { ingest_job_id } = complete
|
||||
|
||||
/* 轮询 ingest job 状态,等待 Worker 处理完成;
|
||||
* complete 后先立即查一次(后端通常不到 1s 处理完),未完成再每 5s 轮询。
|
||||
* 成功状态为 IngestJobStatus.completed;"ready" 是 voice-clones 的状态,此处误用需避免。
|
||||
*/
|
||||
let job = await getIngestJob(ingest_job_id)
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟(60 * 5秒)
|
||||
while (jobStatus !== "ready" && jobStatus !== "failed" && retries < maxRetries) {
|
||||
while (job.status !== "completed" && job.status !== "failed" && retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
const job = await getIngestJob(ingest_job_id)
|
||||
jobStatus = job.status
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
retries++
|
||||
}
|
||||
|
||||
if (jobStatus === "failed") {
|
||||
if (job.status === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
|
||||
@@ -972,76 +972,173 @@
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
配音素材卡片(与配音库Tab集成)
|
||||
配音素材卡片(复用预置音色卡片 .xx-voice-card 布局与播放控件)
|
||||
================================================================ */
|
||||
|
||||
.vmat-card {
|
||||
/* 圆形图标:紫色系(素材统一配色,不用预置音色的性别色) */
|
||||
.vmat-card .vmat-avatar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
}
|
||||
|
||||
/* 左上角批量选择 checkbox */
|
||||
.vmat-card-checkbox {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 3;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.vmat-card:hover .vmat-card-checkbox,
|
||||
.vmat-card-checkbox.checked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vmat-card-checkbox.checked {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 触摸屏无 hover:checkbox 常驻显示 */
|
||||
@media (hover: none) {
|
||||
.vmat-card-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 右上角删除按钮:常驻可见,hover 加深为 danger 色 */
|
||||
.vmat-card-delete {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.vmat-card-delete:hover {
|
||||
background: var(--error-50);
|
||||
color: var(--error-600);
|
||||
}
|
||||
|
||||
/* 选中态高亮(覆盖预置卡片的选中边框) */
|
||||
.vmat-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
}
|
||||
|
||||
/* 信息区给 checkbox / 删除按钮留位 */
|
||||
.vmat-card .vmat-info {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
/* 播放波形/控件继承 .xx-voice-card 网格,无需额外样式 */
|
||||
|
||||
/* 无可播放音频时播放按钮置灰 */
|
||||
.vmat-card .xx-voice-play-btn:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── 批量操作栏 ─────────────────────────────────── */
|
||||
.vmat-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.vmat-batch-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.vmat-batch-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.vmat-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.vmat-checkbox.checked {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.vmat-select-all {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--primary-300);
|
||||
.vmat-select-all:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.vmat-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.vmat-batch-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--primary-color);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.vmat-thumb-icon {
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.vmat-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.vmat-info {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.vmat-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vmat-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
/* ── 骨架屏 ─────────────────────────────────────── */
|
||||
.vmat-card--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vmat-card--skeleton .vmat-thumb {
|
||||
.vmat-skeleton-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.vmat-skeleton-line {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { isAssetUsable } from "@/api/assets"
|
||||
|
||||
describe("isAssetUsable", () => {
|
||||
it("usable 字段缺失时降级为可用(后端字段未上线零影响)", () => {
|
||||
expect(isAssetUsable({})).toBe(true)
|
||||
expect(isAssetUsable({ usable: undefined })).toBe(true)
|
||||
expect(isAssetUsable({ usable: null })).toBe(true)
|
||||
})
|
||||
|
||||
it("usable === true 时可用", () => {
|
||||
expect(isAssetUsable({ usable: true })).toBe(true)
|
||||
})
|
||||
|
||||
it("usable === false 时不可用(已彻底用尽)", () => {
|
||||
expect(isAssetUsable({ usable: false })).toBe(false)
|
||||
expect(isAssetUsable({ usable: false, used_ratio: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it("used_ratio 不影响可用性判断(只影响角标展示)", () => {
|
||||
expect(isAssetUsable({ used_ratio: 0.99 })).toBe(true)
|
||||
expect(isAssetUsable({ used_ratio: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,42 +1,82 @@
|
||||
import React from "react"
|
||||
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
createVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn((x) => x),
|
||||
}))
|
||||
|
||||
const mockGetAssetsByKind = vi.fn()
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAssetDirect: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
|
||||
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "lib-1" }),
|
||||
getAssetsByKind: (...args: unknown[]) => mockGetAssetsByKind(...args),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "proj-1" }),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ enabled, queryFn }: { enabled: boolean; queryFn: () => unknown }) => {
|
||||
// enabled=false 时不发请求(模拟弹窗关闭)
|
||||
if (!enabled) return { data: undefined, isLoading: false }
|
||||
return { data: mockQueryData, isLoading: mockLoading }
|
||||
},
|
||||
}))
|
||||
|
||||
let mockQueryData: unknown = undefined
|
||||
let mockLoading = false
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Modal: ({ open, children, onCancel, onOk, title }: any) =>
|
||||
Modal: ({ open, children, onCancel, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Button: ({ children, onClick, disabled, buttonType }: any) =>
|
||||
React.createElement("button", { onClick, disabled, "data-type": buttonType }, children),
|
||||
}))
|
||||
|
||||
describe("CloneModal", () => {
|
||||
it("should render when closed", () => {
|
||||
it("关闭时不渲染", () => {
|
||||
mockQueryData = undefined
|
||||
const { container } = render(<CloneModal open={false} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("should render input phase when open", () => {
|
||||
const { container } = render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
it("打开时显示「从配音素材选择」和「直接录制」,不再有文件上传入口", () => {
|
||||
mockQueryData = []
|
||||
mockLoading = false
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("从配音素材选择")).toBeTruthy()
|
||||
expect(screen.getByText("直接录制")).toBeTruthy()
|
||||
// 文件上传入口已删除
|
||||
expect(screen.queryByText(/拖拽音频文件/)).toBeNull()
|
||||
expect(screen.queryByText("上传音频")).toBeNull()
|
||||
})
|
||||
|
||||
it("should call onClose when cancel", () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CloneModal open={true} onClose={onClose} />)
|
||||
// just verify render doesn't crash
|
||||
expect(onClose).toBeDefined()
|
||||
it("素材为空时提示先上传素材并给跳转入口", () => {
|
||||
mockQueryData = []
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("请先在配音库上传素材")).toBeTruthy()
|
||||
expect(screen.getByText("去配音库上传")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("有素材时下拉展示素材名和时长", () => {
|
||||
mockQueryData = [
|
||||
{ id: "a1", name: "旁白录音.m4a", duration: 65 },
|
||||
{ id: "a2", name: "访谈.mp3", duration: undefined },
|
||||
]
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("旁白录音.m4a(01:05)")).toBeTruthy()
|
||||
expect(screen.getByText("访谈.mp3(--:--)")).toBeTruthy()
|
||||
// 空态提示不出现
|
||||
expect(screen.queryByText("请先在配音库上传素材")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Smoke test for utils
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal/utils"
|
||||
|
||||
describe("utils smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@ import "@/pages/assets/components/CreateLibraryModal"
|
||||
import "@/pages/assets/components/LibrarySidebar"
|
||||
import "@/pages/assets/components/PlayModal"
|
||||
import "@/pages/assets/components/ResultDrawer"
|
||||
import "@/pages/assets/components/UploadProgressModal"
|
||||
import "@/pages/assets/components/UploadQueuePanel"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/assets/types"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getUsageBadge } from "@/pages/assets/types"
|
||||
|
||||
describe("getUsageBadge", () => {
|
||||
it("非视频素材不显示角标", () => {
|
||||
expect(getUsageBadge({ kind: "voice", usable: false })).toBeNull()
|
||||
expect(getUsageBadge({ kind: "image", usable: false })).toBeNull()
|
||||
})
|
||||
|
||||
it("字段缺失时不显示角标(降级零影响)", () => {
|
||||
expect(getUsageBadge({ kind: "video" })).toBeNull()
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: undefined })).toBeNull()
|
||||
})
|
||||
|
||||
it("usable === false 显示红色实心「已用尽」", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: false, usedRatio: 1 })).toEqual({
|
||||
label: "已用尽",
|
||||
variant: "exhausted",
|
||||
})
|
||||
// usable === false 优先级最高,即使 usedRatio 字段缺失
|
||||
expect(getUsageBadge({ kind: "video", usable: false })).toEqual({
|
||||
label: "已用尽",
|
||||
variant: "exhausted",
|
||||
})
|
||||
})
|
||||
|
||||
it("used_ratio >= 0.85 显示红色「即将用尽」", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.85 })).toEqual({
|
||||
label: "即将用尽",
|
||||
variant: "warning",
|
||||
})
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.97 })).toEqual({
|
||||
label: "即将用尽",
|
||||
variant: "warning",
|
||||
})
|
||||
})
|
||||
|
||||
it("used_ratio >= 0.5 显示橙色「已用 xx%」", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.5 })).toEqual({
|
||||
label: "已用 50%",
|
||||
variant: "ratio",
|
||||
})
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.84 })).toEqual({
|
||||
label: "已用 84%",
|
||||
variant: "ratio",
|
||||
})
|
||||
})
|
||||
|
||||
it("used_ratio < 0.5 不显示角标", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.49 })).toBeNull()
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0 })).toBeNull()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user