Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1eb9d8667a | |||
| d3fc15ddd9 | |||
| 2957ad724c |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -136,4 +140,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -57,8 +57,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit;
|
||||
// 此前在此上锁且无复位路径,弹窗打开即死锁
|
||||
setErrorMessage("")
|
||||
resetRecorder()
|
||||
}, [getNextDefaultName, resetRecorder])
|
||||
@@ -99,8 +99,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
|
||||
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
@@ -132,8 +132,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
|
||||
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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"
|
||||
@@ -34,95 +34,106 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
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>
|
||||
)}
|
||||
}) => {
|
||||
// 视频素材余量角标(已用尽/即将用尽/已用 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>
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - 步骤 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"
|
||||
@@ -217,6 +217,22 @@ 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,
|
||||
@@ -300,7 +316,7 @@ const GeneratePage: React.FC = () => {
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onGenerate={handleGenerate}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
|
||||
@@ -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 < 6 ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,7 +78,7 @@ 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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import { useState, useCallback, useMemo } 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
|
||||
@@ -32,38 +32,41 @@ 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)
|
||||
setSmartMatchedResults(matched)
|
||||
setHasMatched(true)
|
||||
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
|
||||
} else {
|
||||
// 后端返回空结果,回退到全选
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
// 后端返回空结果,回退到全选可用素材
|
||||
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
|
||||
setSmartMatchedResults(usableItems)
|
||||
setHasMatched(true)
|
||||
message.info("AI 暂未找到匹配素材,已全选当前库素材")
|
||||
}
|
||||
} catch {
|
||||
// 后端 API 尚未就绪时,回退到全选当前库素材
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
// 后端 API 尚未就绪时,回退到全选当前库可用素材
|
||||
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
|
||||
setSmartMatchedResults(usableItems)
|
||||
setHasMatched(true)
|
||||
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
|
||||
} finally {
|
||||
@@ -77,7 +80,7 @@ export function useSmartMatch({
|
||||
}, [handleSmartMatch])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
onSmartSelectedIdsChange(materials.items.filter(isAssetUsable).map((a) => a.id))
|
||||
}, [materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
@@ -99,7 +102,7 @@ export function useSmartMatch({
|
||||
const smartSelectedTotalDuration = useMemo(
|
||||
() =>
|
||||
materials.items
|
||||
.filter((a) => smartSelectedIds.includes(a.id))
|
||||
.filter((a) => isAssetUsable(a) && smartSelectedIds.includes(a.id))
|
||||
.reduce((sum, a) => sum + (a.duration || 0), 0),
|
||||
[materials.items, smartSelectedIds],
|
||||
)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -38,12 +38,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,
|
||||
materials: selectableMaterials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
})
|
||||
@@ -59,13 +66,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 +182,8 @@ export function useStep2Materials({
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
// 模式
|
||||
materialMode,
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
|
||||
+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,
|
||||
@@ -203,8 +227,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 +243,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 +263,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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,94 @@ 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
|
||||
const progress = 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={() => {}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -1,232 +1,242 @@
|
||||
/**
|
||||
* useAudioPlayer hook 测试 — VoiceLibrary 版本
|
||||
* useAudioPlayer hook 测试 — VoiceLibrary 版本(真实 Audio + TTS 试听)
|
||||
*
|
||||
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测。
|
||||
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
|
||||
* mock HTMLAudioElement 与 previewTts,验证三 tab 共用的播放状态逻辑。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
|
||||
import type { VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
|
||||
describe("useAudioPlayer (voices)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
const mockAudioPlay = vi.fn()
|
||||
const mockAudioPause = vi.fn()
|
||||
const listeners: Record<string, (() => void) | ((ev: unknown) => void)> = {}
|
||||
let mockAudioInstance: {
|
||||
play: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
addEventListener: (ev: string, cb: () => void) => void
|
||||
currentTime: number
|
||||
duration: number
|
||||
src: string
|
||||
volume: number
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
const previewTtsMock = vi.fn()
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
previewTts: (...args: unknown[]) => previewTtsMock(...args),
|
||||
}))
|
||||
|
||||
const mockMaterial: VoiceMaterial = {
|
||||
id: "asset-1",
|
||||
name: "测试素材",
|
||||
description: "",
|
||||
gender: "neutral",
|
||||
tagIds: [],
|
||||
fileName: "test.mp3",
|
||||
fileSize: 1024,
|
||||
duration: 30,
|
||||
mimeType: "audio/mpeg",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
fileUrl: "https://example.com/test.mp3",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
for (const k of Object.keys(listeners)) delete listeners[k]
|
||||
|
||||
mockAudioInstance = {
|
||||
play: mockAudioPlay.mockResolvedValue(undefined),
|
||||
pause: mockAudioPause,
|
||||
addEventListener: (ev: string, cb: () => void) => {
|
||||
listeners[ev] = cb
|
||||
},
|
||||
currentTime: 0,
|
||||
duration: 30,
|
||||
src: "",
|
||||
volume: 1,
|
||||
paused: true,
|
||||
}
|
||||
|
||||
global.Audio = vi.fn().mockImplementation((url: string) => {
|
||||
mockAudioInstance.src = url
|
||||
return mockAudioInstance
|
||||
}) as unknown as typeof Audio
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("useAudioPlayer (voices, 真实播放)", () => {
|
||||
it("初始状态为空", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.loadingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("应该使用初始状态初始化", () => {
|
||||
it("playMaterial 用 file_url 真实播放素材", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("asset-1")
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudioInstance.src).toBe("https://example.com/test.mp3")
|
||||
})
|
||||
|
||||
it("file_url 缺失时不播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial({ ...mockMaterial, fileUrl: undefined })
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(mockAudioPlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handleToggleMaterial 播放中再点为暂停", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial)
|
||||
})
|
||||
expect(result.current.playingId).toBe("asset-1")
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial)
|
||||
})
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(mockAudioPause).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停后再次点击恢复播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial) // 暂停
|
||||
})
|
||||
mockAudioPlay.mockClear()
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial) // 恢复
|
||||
})
|
||||
expect(result.current.playingId).toBe("asset-1")
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("playVoice 合成中显示 loading,成功后播放且二次点用缓存不重复合成", async () => {
|
||||
previewTtsMock.mockResolvedValue({ audio_url: "https://example.com/tts.mp3", duration: 5 })
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.playVoice("voice-1")
|
||||
})
|
||||
|
||||
expect(previewTtsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ voice_id: "voice-1", speed: 1.0 }),
|
||||
)
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(mockAudioInstance.src).toBe("https://example.com/tts.mp3")
|
||||
|
||||
// 停止后再次试听同一音色 → 走缓存
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
mockAudioPlay.mockClear()
|
||||
previewTtsMock.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.playVoice("voice-1")
|
||||
})
|
||||
expect(previewTtsMock).not.toHaveBeenCalled()
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("合成失败时复位 loading 且不播放", async () => {
|
||||
previewTtsMock.mockRejectedValue(new Error("克隆尚未完成"))
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.playVoice("voice-x")
|
||||
})
|
||||
|
||||
expect(result.current.loadingId).toBeNull()
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(mockAudioPlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("互斥:播放新素材时停止上一个", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
act(() => {
|
||||
result.current.playMaterial({
|
||||
...mockMaterial,
|
||||
id: "asset-2",
|
||||
fileUrl: "https://e.com/2.mp3",
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("asset-2")
|
||||
expect(mockAudioPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("handleSeek 播放中调整 currentTime", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSeek(12)
|
||||
})
|
||||
|
||||
expect(mockAudioInstance.currentTime).toBe(12)
|
||||
})
|
||||
|
||||
it("timeupdate 更新进度、ended 复位", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockAudioInstance.currentTime = 7
|
||||
;(listeners["timeupdate"] as () => void)()
|
||||
})
|
||||
expect(result.current.currentTime).toBe(7)
|
||||
|
||||
act(() => {
|
||||
;(listeners["ended"] as () => void)()
|
||||
})
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 应该开始播放指定音色", () => {
|
||||
it("stopPlayback 复位全部状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 对同一个音色不应重复启动播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
const initialTime = result.current.currentTime
|
||||
|
||||
// 推进一些时间让进度走动
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeAfterAdvance = result.current.currentTime
|
||||
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
|
||||
|
||||
// 对同一个音色再次调用 handlePlay 不应重置
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(timeAfterAdvance)
|
||||
})
|
||||
|
||||
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-2", 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("播放进度应该随时间递增", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
// 每 100ms 增加 0.1
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
})
|
||||
|
||||
it("播放到结尾应自动停止并重置", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600) // 超过 0.5 秒
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePause 应该暂停播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeBeforePause = result.current.currentTime
|
||||
|
||||
act(() => {
|
||||
result.current.handlePause()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
|
||||
// 暂停后时间不应再变化
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(timeBeforePause)
|
||||
})
|
||||
|
||||
it("handleSeek 应该跳转到指定时间", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-1", 5, 10)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(5)
|
||||
})
|
||||
|
||||
it("handleSeek 对不同音色应该开始播放该音色", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-2", 3, 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(3)
|
||||
})
|
||||
|
||||
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
// 初始为暂停,调用应开始播放
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
|
||||
// 再次调用应暂停
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
})
|
||||
|
||||
it("stopPlayback 应该重置所有播放状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
|
||||
// 停止后定时器不应再触发
|
||||
const timeAfterStop = result.current.currentTime
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(result.current.currentTime).toBe(timeAfterStop)
|
||||
})
|
||||
|
||||
it("返回值应该包含所有必要的方法和状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(typeof result.current.handlePlay).toBe("function")
|
||||
expect(typeof result.current.handlePause).toBe("function")
|
||||
expect(typeof result.current.handleSeek).toBe("function")
|
||||
expect(typeof result.current.handleTogglePlay).toBe("function")
|
||||
expect(typeof result.current.stopPlayback).toBe("function")
|
||||
expect(typeof result.current.playingId).toBe("object") // string | null
|
||||
expect(typeof result.current.currentTime).toBe("number")
|
||||
expect(mockAudioPause).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import "@/pages/voices/components/tts-modal/ErrorAlert"
|
||||
import "@/pages/voices/components/tts-modal/ResultPanel"
|
||||
import "@/pages/voices/components/tts-modal/types"
|
||||
import "@/pages/voices/components/VoiceFilterBar"
|
||||
import "@/pages/voices/components/MaterialVoiceCard"
|
||||
import "@/pages/voices/components/MaterialVoiceTab"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/voices/types"
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
def infer_mime_type_from_storage_key(storage_key: str) -> str:
|
||||
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
|
||||
# Video
|
||||
if lower_filename.endswith(".mov"):
|
||||
return "video/quicktime"
|
||||
if lower_filename.endswith((".mp4", ".m4v", ".avi", ".mkv", ".webm")):
|
||||
return "video/mp4"
|
||||
# Audio
|
||||
if lower_filename.endswith(".m4a"):
|
||||
return "audio/mp4"
|
||||
if lower_filename.endswith(".mp3"):
|
||||
return "audio/mpeg"
|
||||
if lower_filename.endswith(".wav"):
|
||||
return "audio/wav"
|
||||
if lower_filename.endswith(".flac"):
|
||||
return "audio/flac"
|
||||
if lower_filename.endswith(".ogg"):
|
||||
return "audio/ogg"
|
||||
if lower_filename.endswith(".aac"):
|
||||
return "audio/aac"
|
||||
if lower_filename.endswith(".wma"):
|
||||
return "audio/x-ms-wma"
|
||||
if lower_filename.endswith(".amr"):
|
||||
return "audio/amr"
|
||||
if lower_filename.endswith(".opus"):
|
||||
return "audio/opus"
|
||||
if lower_filename.endswith(".weba"):
|
||||
return "audio/webm"
|
||||
# Default fallback
|
||||
return "image/jpeg"
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import List
|
||||
from typing import Callable, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
@@ -204,6 +207,7 @@ def _calc_random_start_time(
|
||||
clip_duration: float,
|
||||
asset_durations: dict[str, float] | None,
|
||||
used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
|
||||
) -> float | None:
|
||||
"""计算随机 start_time,避开已使用的时间段.
|
||||
|
||||
@@ -216,6 +220,10 @@ def _calc_random_start_time(
|
||||
clip_duration: 片段时长(秒)
|
||||
asset_durations: 素材 ID -> 时长映射
|
||||
used_segments: {asset_id: [(start1, end1), (start2, end2), ...]} 已使用的时间段
|
||||
on_exhausted: 100 次随机都找不到空闲区间时的受控复用回调,入参为
|
||||
(asset_id, clip_duration),返回 (start, end) 复用区间或 None。
|
||||
历史记录永不自动清空;回调返回 None(全部达上限/复用占比超闸门)时
|
||||
本函数返回 None,由调用方轮询下一个素材或报错,不做重叠降级。
|
||||
|
||||
Returns:
|
||||
随机 start_time 或 None
|
||||
@@ -255,8 +263,36 @@ def _calc_random_start_time(
|
||||
if not overlap:
|
||||
return candidate
|
||||
|
||||
# 如果尝试多次仍找不到,缩短时长使用素材末尾
|
||||
# 找到最后一个已使用段之后的可用空间
|
||||
# 100 次都找不到空闲区间:进入受控复用,回调从历史区间中选最久未用且
|
||||
# 使用次数未达上限的区间返回(历史记录永不自动清空)
|
||||
if on_exhausted is not None:
|
||||
try:
|
||||
reused = on_exhausted(asset_id, clip_duration)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"on_exhausted 受控复用回调异常: asset_id=%s",
|
||||
asset_id,
|
||||
exc_info=True,
|
||||
)
|
||||
reused = None
|
||||
if reused is not None:
|
||||
reuse_start, reuse_end = reused
|
||||
# 边界保护:不越素材末尾、不为负
|
||||
reuse_start = max(0.0, min(float(reuse_start), max_start))
|
||||
logger.info(
|
||||
"素材可用区间耗尽,受控复用历史区间: asset_id=%s start=%.2f end=%.2f",
|
||||
asset_id,
|
||||
reuse_start,
|
||||
reuse_end,
|
||||
)
|
||||
return reuse_start
|
||||
# 回调存在但拒绝复用(区间全部达 use_count 上限,或复用占比将超 15% 闸门):
|
||||
# 返回 None,由调用方轮询下一个素材;绝不能末尾/0.0 降级——那会把片段
|
||||
# 放回到已用过的画面,违反区间避让与重复率控制原则
|
||||
return None
|
||||
|
||||
# 未提供 on_exhausted 回调(向后兼容):降级使用素材末尾空闲位置;
|
||||
# 末尾也已占满时返回 0.0(旧行为,仅无持久化追踪的调用方会走到这里)
|
||||
last_used_end = 0.0
|
||||
for _seg_start, seg_end in used:
|
||||
last_used_end = max(last_used_end, seg_end)
|
||||
@@ -265,7 +301,6 @@ def _calc_random_start_time(
|
||||
# 返回从最后使用点开始的位置
|
||||
return min(last_used_end, max_start)
|
||||
|
||||
# 实在没有空间,返回0(可能会重叠,但至少能执行)
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# CI 公共步骤:检测 push(develop/main) 事件的改动范围
|
||||
# 输出 skip_backend / skip_frontend(复用 PR check 的语义)
|
||||
# - 纯前端改动(仅 apps/web/): skip_backend=true
|
||||
# - 纯后端改动(不含 apps/web/): skip_frontend=true
|
||||
# - 全栈 / 无法判断: 两者都 false(走全量,安全兜底)
|
||||
# 需要环境变量: GITHUB_TOKEN, GITHUB_API_URL, GITHUB_REPOSITORY, GITHUB_SHA
|
||||
set -eu
|
||||
|
||||
OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
|
||||
before="${GITHUB_EVENT_BEFORE:-}"
|
||||
after="${GITHUB_SHA:-}"
|
||||
repo="${GITHUB_REPOSITORY:-}"
|
||||
base="${GITHUB_API_URL:-}"
|
||||
|
||||
# Gitea Actions 中 push 事件的前一个 SHA 在 event payload 的 before 字段
|
||||
if [ -z "$before" ] && [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
||||
before=$(python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(open('${GITHUB_EVENT_PATH}'))
|
||||
print(d.get('before','') or '')
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
fi
|
||||
|
||||
echo "改动范围检测: before=${before:-<empty>} after=${after}"
|
||||
|
||||
FILES=""
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
# Gitea 1.26.x compare API 的顶层 files 字段不填充(始终为空),
|
||||
# 但响应里每个 commit 条目自带的 files 完整可用;聚合区间内所有提交的 files 即可。
|
||||
API_URL="${base}/repos/${repo}/compare/${before}...${after}?per_page=300"
|
||||
for attempt in 1 2 3; do
|
||||
FILES=$(curl -s --max-time 30 -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" \
|
||||
| python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
files=set()
|
||||
for c in d.get('commits', []) or []:
|
||||
for f in (c.get('files') or []):
|
||||
n = f.get('filename') or ''
|
||||
if n:
|
||||
files.add(n)
|
||||
print('\n'.join(sorted(files)))
|
||||
except Exception:
|
||||
pass
|
||||
")
|
||||
[ -n "$FILES" ] && break
|
||||
echo "compare API 无返回,重试 $attempt/3..."
|
||||
sleep 3
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "⚠️ 无法获取改动文件列表(新分支/API异常),保守起见走全量构建"
|
||||
echo "skip_backend=false" >> "$OUTPUT"
|
||||
echo "skip_frontend=false" >> "$OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TOTAL=$(printf '%s\n' "$FILES" | grep -c . || true)
|
||||
FRONTEND_COUNT=$(printf '%s\n' "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(python3 -c "print($TOTAL - $FRONTEND_COUNT)")
|
||||
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt 0 ]; then
|
||||
echo "skip_backend=true" >> "$OUTPUT"
|
||||
echo "skip_frontend=false" >> "$OUTPUT"
|
||||
echo "✅ 纯前端改动,跳过后端镜像构建"
|
||||
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt 0 ]; then
|
||||
echo "skip_backend=false" >> "$OUTPUT"
|
||||
echo "skip_frontend=true" >> "$OUTPUT"
|
||||
echo "🔧 纯后端改动,跳过 Web 镜像构建"
|
||||
else
|
||||
echo "skip_backend=false" >> "$OUTPUT"
|
||||
echo "skip_frontend=false" >> "$OUTPUT"
|
||||
echo "🔧 包含全栈/公共变更,三个镜像全部构建"
|
||||
fi
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
# PR构建专用:只构建不输出,验证Dockerfile能否正常构建
|
||||
# 无本地缓存(12个runner不共享,反而添乱),只用ACR远程缓存
|
||||
# 缓存:复用宿主机持久 builder (ci-builder-persist) 的层缓存 + ACR registry 缓存兜底
|
||||
# 无状态:build-only 不推送,job 结束无需清理(builder 为共享持久资源)
|
||||
set -eu
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
@@ -18,26 +19,61 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
BUILDER_NAME="ci-builder-persist"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--buildkitd-flags "--allow-insecure-entitlement network.host" \
|
||||
--platform linux/amd64
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
docker buildx inspect "$BUILDER_NAME" --bootstrap
|
||||
|
||||
echo "=== PR Build: build only, no output, remote cache only ==="
|
||||
echo "=== PR Build: build only, no push (persistent builder cache) ==="
|
||||
echo "Dockerfile: ${DOCKERFILE}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
echo "Builder: ${BUILDER_NAME}"
|
||||
echo ""
|
||||
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
.
|
||||
run_build() {
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
.
|
||||
}
|
||||
|
||||
# PR 构建同样容错:检测到 builder 缓存损坏时,用 flock 串行重建共享 builder 后重试一次
|
||||
if ! build_output=$(run_build 2>&1); then
|
||||
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key|no such file or directory.*cache"; then
|
||||
echo "$build_output"
|
||||
echo "⚠️ builder 缓存异常,串行重建持久 builder 后重试..."
|
||||
LOCK_FILE="/tmp/ci-builder-persist-rebuild.lock"
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -w 120 9 || echo "⚠️ 等待重建锁超时,直接重试 build"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" --bootstrap >/dev/null 2>&1; then
|
||||
echo "🔨 锁内重建持久 builder..."
|
||||
docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true
|
||||
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--buildkitd-flags "--allow-insecure-entitlement network.host" \
|
||||
--platform linux/amd64
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
docker buildx inspect "$BUILDER_NAME" --bootstrap
|
||||
else
|
||||
echo "✅ builder 已被其他并发 job 重建/恢复,直接复用"
|
||||
fi
|
||||
run_build
|
||||
else
|
||||
echo "$build_output"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "$build_output"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PR build OK (build only, no output): ${IMAGE_TAG}"
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
#!/bin/bash
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享)
|
||||
# 通用Docker镜像构建+推送脚本
|
||||
# 缓存策略(2026-08 起):
|
||||
# - buildx 使用宿主机持久 builder (ci-builder-persist),层缓存保存在
|
||||
# buildkit 容器/命名卷中,跨 job 共享、job 结束不清理
|
||||
# - registry cache 仅作为冷启动兜底读取
|
||||
# - 额外 tag(如分支 tag :develop)通过 EXTRA_TAGS 环境变量传入,随构建一并推送
|
||||
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
# 环境变量:
|
||||
# EXTRA_TAGS 空格分隔的额外 tag(完整 image:tag 引用),可选
|
||||
set -eu
|
||||
|
||||
# 单次 build 超时时间(秒),防止 docker buildx build 无限挂起
|
||||
BUILD_TIMEOUT=1500
|
||||
# 持久 builder 名(宿主机级,所有 CI job 共享;由 ensure_persistent_builder.sh 维护)
|
||||
BUILDER_NAME="ci-builder-persist"
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
if [ "$1" = "--no-cache" ]; then
|
||||
@@ -22,23 +31,27 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
# 确保持久 builder 存在并使用(幂等)
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--buildkitd-flags "--allow-insecure-entitlement network.host" \
|
||||
--platform linux/amd64
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
docker buildx inspect "$BUILDER_NAME" --bootstrap
|
||||
|
||||
# 从cache_ref中提取缓存名称(如 api-cache:develop -> api-cache-develop)
|
||||
CACHE_NAME=$(echo "$CACHE_REF" | tr '/' '_' | tr ':' '-')
|
||||
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
# 组装额外 tag 参数
|
||||
EXTRA_TAG_FLAGS=""
|
||||
EXTRA_TAG_LIST=""
|
||||
if [ -n "${EXTRA_TAGS:-}" ]; then
|
||||
for t in $EXTRA_TAGS; do
|
||||
EXTRA_TAG_FLAGS="$EXTRA_TAG_FLAGS -t $t"
|
||||
EXTRA_TAG_LIST="$EXTRA_TAG_LIST $t"
|
||||
done
|
||||
fi
|
||||
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
# 缓存源:local优先(带自动修复),registry兜底读写
|
||||
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
|
||||
build_with_cache_retry() {
|
||||
local attempt=1
|
||||
local max_attempts=2
|
||||
@@ -49,12 +62,10 @@ build_with_cache_retry() {
|
||||
build_output=$(timeout ${BUILD_TIMEOUT} docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
$EXTRA_TAG_FLAGS \
|
||||
--push \
|
||||
. 2>&1)
|
||||
exit_code=$?
|
||||
@@ -69,48 +80,55 @@ build_with_cache_retry() {
|
||||
echo "$build_output" | tail -20
|
||||
return $exit_code
|
||||
fi
|
||||
# 检测到缓存损坏类错误,清掉本地缓存重试
|
||||
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
|
||||
# 检测到缓存/快照损坏类错误,重建 builder 后重试
|
||||
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key|no such file or directory.*cache"; then
|
||||
echo "$build_output"
|
||||
echo ""
|
||||
echo "⚠️ Local cache appears corrupted, cleaning up and retrying (attempt $attempt/$max_attempts)..."
|
||||
rm -rf "${LOCAL_CACHE_DIR}"
|
||||
mkdir -p "${LOCAL_CACHE_DIR}"
|
||||
# 清理buildx builder的内部snapshot状态
|
||||
docker buildx prune -f -a > /dev/null 2>&1 || true
|
||||
echo "⚠️ builder 缓存异常,重建持久 builder 后重试 (attempt $attempt/$max_attempts)..."
|
||||
# 共享 builder 的重建必须串行:ci-builder-persist 被所有 build job 共用,
|
||||
# 若 job A 正在构建、job B 检测到损坏直接 rm,会把 A 正在用的 buildkit 杀掉。
|
||||
# 用 flock 串行化重建;拿到锁后再次检查 builder 健康度,已被别的 job 重建则直接复用。
|
||||
LOCK_FILE="/tmp/ci-builder-persist-rebuild.lock"
|
||||
exec 9>"$LOCK_FILE"
|
||||
echo "🔒 等待重建锁(最多 120s)..."
|
||||
if flock -w 120 9; then
|
||||
if docker buildx inspect "$BUILDER_NAME" --bootstrap >/dev/null 2>&1; then
|
||||
echo "✅ builder 已被其他并发 job 重建/恢复,直接复用"
|
||||
else
|
||||
echo "🔨 锁内重建持久 builder..."
|
||||
docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true
|
||||
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--buildkitd-flags "--allow-insecure-entitlement network.host" \
|
||||
--platform linux/amd64
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
docker buildx inspect "$BUILDER_NAME" --bootstrap
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 等待重建锁超时,直接重试 build(失败将重试/--no-cache)"
|
||||
docker buildx use "$BUILDER_NAME" 2>/dev/null || true
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
else
|
||||
# 非缓存类错误,直接输出并返回
|
||||
echo "$build_output"
|
||||
return $exit_code
|
||||
fi
|
||||
done
|
||||
# 重试完还是失败,不用本地缓存最后试一次(只从registry读)
|
||||
echo "⚠️ All cached attempts failed, building without local cache..."
|
||||
timeout ${BUILD_TIMEOUT} docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
.
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
|
||||
echo "Local cache: ${LOCAL_CACHE_DIR}"
|
||||
echo "Registry cache: ${CACHE_REF}"
|
||||
echo "Build timeout: ${BUILD_TIMEOUT}s"
|
||||
echo "=== Build & push image (persistent builder cache) ==="
|
||||
echo "Builder: ${BUILDER_NAME} (persistent)"
|
||||
echo "Registry cache(from): ${CACHE_REF}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
[ -n "$EXTRA_TAG_LIST" ] && echo "Extra tags: ${EXTRA_TAG_LIST}"
|
||||
echo "Timeout: ${BUILD_TIMEOUT}s"
|
||||
echo ""
|
||||
|
||||
build_with_cache_retry
|
||||
|
||||
echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
echo "Registry cache updated (if supported)"
|
||||
|
||||
[ -n "$EXTRA_TAG_LIST" ] && echo "Also pushed: ${EXTRA_TAG_LIST}"
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# CI 公共步骤:确保宿主机持久 buildx builder 存在(DooD 模式下所有 job 共享)
|
||||
# - builder 名固定: ci-builder-persist
|
||||
# - docker-container driver, host 网络
|
||||
# - 层缓存保存在 buildkit 容器及其 _state 命名卷中,job 结束不清理
|
||||
# - 宿主机 ci-docker-cleanup.sh 已豁免该 builder
|
||||
# 用法: bash scripts/ci/ensure_persistent_builder.sh
|
||||
set -eu
|
||||
|
||||
BUILDER="ci-builder-persist"
|
||||
|
||||
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
|
||||
echo "=== 创建持久 buildx builder: $BUILDER ==="
|
||||
# 并发安全:matrix 多个 job 可能同时检测到 builder 不存在,只有一个 create 成功;
|
||||
# 其余 job 的 create 会因 "builder already exists" 失败(set -e 下会退出)。
|
||||
# 用 create || inspect 兜底:create 失败时若 builder 实际已被别的 job 创建,直接复用。
|
||||
if ! docker buildx create --name "$BUILDER" --driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--buildkitd-flags "--allow-insecure-entitlement network.host" \
|
||||
--platform linux/amd64 2>/tmp/_buildx_create.err; then
|
||||
if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
|
||||
echo "=== builder 已被并发任务创建,复用: $BUILDER ==="
|
||||
else
|
||||
echo "❌ builder 创建失败且不存在:"
|
||||
cat /tmp/_buildx_create.err
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "=== 复用持久 buildx builder: $BUILDER ==="
|
||||
fi
|
||||
|
||||
docker buildx use "$BUILDER"
|
||||
docker buildx inspect "$BUILDER" --bootstrap
|
||||
echo "✅ builder ready"
|
||||
docker buildx ls | head -5
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# CI 步骤:未重建的镜像,把 registry 上一个分支 tag 复制为新 SHA tag
|
||||
# 保证 deploy-staging 的 Watchtower 链路三个镜像都有新 SHA 可拉
|
||||
# 用法: bash scripts/ci/retag_skipped_image.sh <image_full_name> <new_sha> <branch>
|
||||
# 例: bash scripts/ci/retag_skipped_image.sh xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/xiaoxia-saas-web <sha> develop
|
||||
set -eu
|
||||
|
||||
IMAGE="$1"
|
||||
NEW_TAG="$2"
|
||||
BRANCH="${3:-develop}"
|
||||
|
||||
NEW_REF="${IMAGE}:${NEW_TAG}"
|
||||
|
||||
echo "=== 复用已有镜像(本次未重建): $IMAGE ==="
|
||||
echo "目标 tag: $NEW_TAG"
|
||||
|
||||
# 源 tag 候选(按优先级)
|
||||
CANDIDATES=()
|
||||
# 1. 分支 tag(构建 job 每次成功都会推)
|
||||
CANDIDATES+=("$BRANCH")
|
||||
# 2. 本 push 的前一个 commit SHA(compare 事件)
|
||||
if [ -n "${GITHUB_EVENT_BEFORE:-}" ] && [ "${GITHUB_EVENT_BEFORE}" != "0000000000000000000000000000000000000000" ]; then
|
||||
CANDIDATES+=("${GITHUB_EVENT_BEFORE}")
|
||||
fi
|
||||
# 3. registry 上最新的 sha 形式 tag(通过 ACR tags API 兜底,不需要额外认证则跳过)
|
||||
|
||||
SRC_TAG=""
|
||||
for cand in "${CANDIDATES[@]}"; do
|
||||
echo "尝试拉取 ${IMAGE}:${cand} ..."
|
||||
# 网络抖动容错:每个候选源最多重试 3 次
|
||||
pull_ok=""
|
||||
for try in 1 2 3; do
|
||||
if docker pull "${IMAGE}:${cand}" >/dev/null 2>&1; then
|
||||
pull_ok="yes"
|
||||
break
|
||||
fi
|
||||
echo " 拉取失败(第 $try/3 次),2s 后重试..."
|
||||
sleep 2
|
||||
done
|
||||
if [ -n "$pull_ok" ]; then
|
||||
SRC_TAG="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$SRC_TAG" ]; then
|
||||
echo "❌ 找不到可复用的源镜像(已尝试: ${CANDIDATES[*]})"
|
||||
echo " 请检查该镜像是否曾成功构建推送,或临时改用全量构建。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 源镜像: ${IMAGE}:${SRC_TAG}"
|
||||
docker tag "${IMAGE}:${SRC_TAG}" "${NEW_REF}"
|
||||
|
||||
# 推新 SHA tag;分支 tag 若指向的就是源 digest 则无需重复,失败可忽略
|
||||
docker push "${NEW_REF}"
|
||||
echo "✅ retag 推送完成: ${NEW_REF} (from ${SRC_TAG})"
|
||||
@@ -63,7 +63,7 @@ class TestGenerationTaskCreate:
|
||||
voice_ids=["v1"],
|
||||
created_by_user_id=" user1 ",
|
||||
source_edit_plan_id=" plan1 ",
|
||||
asset_select_mode="random",
|
||||
asset_select_mode="smart",
|
||||
batch_id="batch1",
|
||||
)
|
||||
assert task.project_id == "proj1"
|
||||
@@ -76,7 +76,7 @@ class TestGenerationTaskCreate:
|
||||
assert task.voice_ids == ["v1"]
|
||||
assert task.created_by_user_id == "user1"
|
||||
assert task.source_edit_plan_id == "plan1"
|
||||
assert task.asset_select_mode == "random"
|
||||
assert task.asset_select_mode == "smart"
|
||||
assert task.batch_id == "batch1"
|
||||
|
||||
def test_create_with_template_instead_of_project(self):
|
||||
|
||||
Executable
+409
@@ -0,0 +1,409 @@
|
||||
"""Task H 单测:素材余量四字段(used_duration/available_duration/used_ratio/usable)。
|
||||
|
||||
覆盖:
|
||||
1. compute_asset_availability 纯函数各分支(无区间/未满/可复用/全达上限/非视频/无时长/区间合并/扩边判定);
|
||||
2. _asset_availability_fields 路由辅助(视频有值、非视频 None+usable=True、异常零影响);
|
||||
3. _to_asset_response 四字段注入;
|
||||
4. smart_match_assets 结果层过滤 usable=false。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from app.api.routes.assets import ( # noqa: E402
|
||||
_asset_availability_fields,
|
||||
_to_asset_response,
|
||||
smart_match_assets,
|
||||
)
|
||||
from app.schemas.asset import SmartMatchRequest # noqa: E402
|
||||
from app.services.asset_segment_tracker import ( # noqa: E402
|
||||
MAX_RANGE_USE_COUNT,
|
||||
SEGMENT_EDGE_GAP,
|
||||
compute_asset_availability,
|
||||
)
|
||||
|
||||
VIDEO_DURATION = 60.0
|
||||
|
||||
|
||||
def _make_asset(duration=VIDEO_DURATION, ranges=None, file_type="video", classification_result=None):
|
||||
"""构造测试用 Asset-like 对象(领域实体形态:metadata 为 dict)。
|
||||
|
||||
ranges: list of dicts(used_time_ranges 条目),同时写入 metadata dict
|
||||
(Asset 实体形态,repository 返回)与 classification_result JSON 字符串
|
||||
(ORM AssetModel 形态);_read_meta 两种形态都必须能读到。
|
||||
"""
|
||||
meta_dict = {"used_time_ranges": ranges} if ranges is not None else {}
|
||||
if classification_result is None and ranges is not None:
|
||||
classification_result = json.dumps(meta_dict)
|
||||
return SimpleNamespace(
|
||||
id="asset-test",
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="测试素材",
|
||||
storage_key="key/test-asset.mp4",
|
||||
thumbnail_url=None,
|
||||
mime_type="video/mp4" if file_type == "video" else "audio/mpeg",
|
||||
file_size=1000,
|
||||
duration=duration,
|
||||
width=1080,
|
||||
height=1920,
|
||||
fps=30,
|
||||
codec="h264",
|
||||
status=SimpleNamespace(value="ready"),
|
||||
classification_status=SimpleNamespace(value="completed"),
|
||||
quality_score=90.0,
|
||||
created_at=__import__("datetime").datetime(2026, 8, 1, 12, 0, 0),
|
||||
uploaded_by_user_id="user-1",
|
||||
tag_ids=[],
|
||||
file_type=file_type,
|
||||
# ORM 形态
|
||||
classification_result=classification_result,
|
||||
# 领域实体形态(真实路由 repository 返回的 Asset)
|
||||
metadata=meta_dict,
|
||||
)
|
||||
|
||||
|
||||
def _make_orm_style_asset(duration=VIDEO_DURATION, ranges=None):
|
||||
"""ORM AssetModel 形态:只有 classification_result JSON 字符串,无 metadata 属性。"""
|
||||
a = _make_asset(duration=duration, ranges=ranges)
|
||||
del a.metadata
|
||||
return a
|
||||
|
||||
|
||||
def _range(start, end, use_count=1):
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"plan_id": "plan-1",
|
||||
"created_at": "2026-08-29T10:00:00",
|
||||
"use_count": use_count,
|
||||
"last_used_at": "2026-08-29T10:00:00",
|
||||
}
|
||||
|
||||
|
||||
# ── compute_asset_availability 纯函数 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComputeAssetAvailability:
|
||||
def test_no_ranges_fully_usable(self):
|
||||
"""无历史区间:used=0, ratio=0, usable=True。"""
|
||||
info = compute_asset_availability(_make_asset(ranges=[]))
|
||||
assert info is not None
|
||||
assert info["used_duration"] == 0.0
|
||||
assert info["available_duration"] == VIDEO_DURATION
|
||||
assert info["used_ratio"] == 0.0
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_none_model_returns_none(self):
|
||||
assert compute_asset_availability(None) is None
|
||||
|
||||
def test_non_video_returns_none(self):
|
||||
"""非视频(音频)返回 None,路由层按可用处理。"""
|
||||
info = compute_asset_availability(_make_asset(file_type="audio"))
|
||||
assert info is None
|
||||
|
||||
def test_zero_duration_returns_none(self):
|
||||
info = compute_asset_availability(_make_asset(duration=0.0))
|
||||
assert info is None
|
||||
|
||||
def test_partial_usage_usable(self):
|
||||
"""使用 10s,剩余 50s 空闲(≥3s),usable=True。"""
|
||||
info = compute_asset_availability(_make_asset(ranges=[_range(5.0, 15.0)]))
|
||||
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
|
||||
assert info["available_duration"] == pytest.approx(50.0, abs=0.01)
|
||||
assert info["used_ratio"] == pytest.approx(10.0 / 60.0, abs=0.001)
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_overlapping_ranges_merged(self):
|
||||
"""重叠区间合并后计算 used_duration,不重复计时。"""
|
||||
info = compute_asset_availability(_make_asset(ranges=[_range(0.0, 10.0), _range(5.0, 20.0)]))
|
||||
# 合并后 [0,20] → 20s
|
||||
assert info["used_duration"] == pytest.approx(20.0, abs=0.01)
|
||||
assert info["used_ratio"] == pytest.approx(20.0 / 60.0, abs=0.001)
|
||||
|
||||
def test_full_coverage_but_reusable(self):
|
||||
"""区间铺满全片(无空闲段),但 use_count 未达上限 → usable=True(受控复用)。"""
|
||||
info = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=10.0,
|
||||
ranges=[_range(0.0, 10.0, use_count=1)],
|
||||
)
|
||||
)
|
||||
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
|
||||
assert info["available_duration"] == 0.0
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_exhausted_not_usable(self):
|
||||
"""无空闲段 且 所有区间 use_count 达上限 → usable=False。"""
|
||||
info = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=10.0,
|
||||
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
|
||||
)
|
||||
)
|
||||
assert info["usable"] is False
|
||||
assert info["available_duration"] == 0.0
|
||||
assert info["used_ratio"] == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
def test_exhausted_multiple_ranges_all_capped(self):
|
||||
"""多个区间铺满、全部达上限 → usable=False;任一未满即 usable=True。"""
|
||||
info_capped = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=20.0,
|
||||
ranges=[
|
||||
_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
_range(10.0, 20.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert info_capped["usable"] is False
|
||||
|
||||
info_partial = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=20.0,
|
||||
ranges=[
|
||||
_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
_range(10.0, 20.0, use_count=MAX_RANGE_USE_COUNT - 1),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert info_partial["usable"] is True
|
||||
|
||||
def test_edge_gap_consumed_not_usable(self):
|
||||
"""区间未物理铺满,但扩边(+0.3s)后空闲段 <3s → 视为无空闲段;
|
||||
区间 use_count 均达上限 → usable=False。"""
|
||||
# 10s 素材:[0, 4.0] 与 [4.6, 10],物理空闲 [4.0,4.6] 仅 0.6s,
|
||||
# 扩边后左区间延至 4.3、右区间起于 4.3,空闲被吃掉
|
||||
info = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=10.0,
|
||||
ranges=[
|
||||
_range(0.0, 4.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
_range(4.6, 10.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert info["usable"] is False
|
||||
|
||||
def test_large_gap_remains_usable(self):
|
||||
"""区间之间留有 ≥3s 空闲段(扩边后仍 ≥3s)→ usable=True。"""
|
||||
# [0,2] 扩边到 [0,2.3],[5.3,10] 扩边前为 [5,10] 扩边起 4.7;空闲 [2.3,4.7]=2.4s <3
|
||||
# 改用更大间隙:[0,2] 与 [6,10],扩边后空闲 [2.3,5.7]=3.4s ≥3
|
||||
info = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=10.0,
|
||||
ranges=[
|
||||
_range(0.0, 2.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
_range(6.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_invalid_ranges_skipped(self):
|
||||
"""脏数据(缺 start/end、end<=start、use_count 非法)不崩溃,合法区间照常计算。"""
|
||||
info = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=30.0,
|
||||
ranges=[
|
||||
{"start": "bad"},
|
||||
{"start": 5.0, "end": 3.0},
|
||||
"junk",
|
||||
_range(0.0, 10.0, use_count="not-a-number"),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert info is not None
|
||||
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
|
||||
# use_count 非法按 1 处理 → 未达上限,且空闲段充足
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_broken_classification_json_treated_as_unused(self):
|
||||
"""classification_result 是非法 JSON 时按无历史区间处理。"""
|
||||
info = compute_asset_availability(_make_asset(classification_result="not-json{{{"))
|
||||
assert info is not None
|
||||
assert info["used_duration"] == 0.0
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_segment_edge_gap_constant(self):
|
||||
"""边缘间隙常量为 0.3s(与 MediaKit 冲突检测同口径)。"""
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
|
||||
def test_domain_entity_metadata_dict_form(self):
|
||||
"""领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。
|
||||
|
||||
真实路由 repository 返回 Asset 实体,区间记录在 metadata dict 里
|
||||
(repository 与 ORM classification_result JSON 互转)。
|
||||
"""
|
||||
a = _make_asset(duration=30.0, ranges=[_range(0.0, 12.0)])
|
||||
del a.classification_result # 实体没有该列
|
||||
info = compute_asset_availability(a)
|
||||
assert info is not None
|
||||
assert info["used_duration"] == pytest.approx(12.0, abs=0.01)
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_orm_model_classification_result_form(self):
|
||||
"""ORM AssetModel 形态(只有 classification_result JSON 字符串)正常。"""
|
||||
a = _make_orm_style_asset(duration=30.0, ranges=[_range(0.0, 12.0)])
|
||||
assert not hasattr(a, "metadata")
|
||||
info = compute_asset_availability(a)
|
||||
assert info is not None
|
||||
assert info["used_duration"] == pytest.approx(12.0, abs=0.01)
|
||||
|
||||
|
||||
# ── 路由层辅助:_asset_availability_fields / _to_asset_response ──────────────
|
||||
|
||||
|
||||
class TestAssetAvailabilityFields:
|
||||
def test_video_asset_returns_values(self):
|
||||
fields = _asset_availability_fields(_make_asset(ranges=[_range(0.0, 10.0)]))
|
||||
assert fields["usable"] is True
|
||||
assert fields["used_duration"] == pytest.approx(10.0, abs=0.01)
|
||||
assert fields["available_duration"] == pytest.approx(50.0, abs=0.01)
|
||||
assert fields["used_ratio"] is not None
|
||||
|
||||
def test_non_video_returns_none_fields_usable_true(self):
|
||||
fields = _asset_availability_fields(_make_asset(file_type="audio"))
|
||||
assert fields["used_duration"] is None
|
||||
assert fields["available_duration"] is None
|
||||
assert fields["used_ratio"] is None
|
||||
assert fields["usable"] is True
|
||||
|
||||
def test_exception_falls_back_to_zero_impact(self, monkeypatch):
|
||||
"""compute 抛异常时路由层兜底:None 字段 + usable=True,不影响响应。"""
|
||||
import app.api.routes.assets as assets_module
|
||||
|
||||
def _boom(_model):
|
||||
raise RuntimeError("unexpected")
|
||||
|
||||
monkeypatch.setattr(assets_module, "compute_asset_availability", _boom)
|
||||
fields = _asset_availability_fields(_make_asset())
|
||||
assert fields["used_duration"] is None
|
||||
assert fields["usable"] is True
|
||||
|
||||
|
||||
class TestToAssetResponseInjectsFields:
|
||||
def _storage_stub(self):
|
||||
svc = MagicMock()
|
||||
svc.get_download_url.return_value = "https://example.com/signed"
|
||||
return svc
|
||||
|
||||
def test_video_response_carries_availability_fields(self):
|
||||
asset = _make_asset(ranges=[_range(0.0, 12.0)])
|
||||
resp = _to_asset_response(asset, storage_service=self._storage_stub())
|
||||
assert resp.usable is True
|
||||
assert resp.used_duration == pytest.approx(12.0, abs=0.01)
|
||||
assert resp.available_duration == pytest.approx(48.0, abs=0.01)
|
||||
assert resp.used_ratio == pytest.approx(0.2, abs=0.01)
|
||||
|
||||
def test_exhausted_asset_response_usable_false(self):
|
||||
asset = _make_asset(
|
||||
duration=10.0,
|
||||
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
|
||||
)
|
||||
resp = _to_asset_response(asset, storage_service=self._storage_stub())
|
||||
assert resp.usable is False
|
||||
assert resp.used_ratio == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
def test_non_video_response_fields_none_usable_true(self):
|
||||
asset = _make_asset(file_type="audio")
|
||||
resp = _to_asset_response(asset, storage_service=self._storage_stub())
|
||||
assert resp.used_duration is None
|
||||
assert resp.available_duration is None
|
||||
assert resp.used_ratio is None
|
||||
assert resp.usable is True
|
||||
|
||||
|
||||
# ── smart_match_assets 结果层过滤 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _exhausted_asset(asset_id):
|
||||
"""构造一个 usable=false 的视频素材:10s 铺满、区间 use_count 均达上限。"""
|
||||
a = _make_asset(
|
||||
duration=10.0,
|
||||
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
|
||||
)
|
||||
a.id = asset_id
|
||||
a.name = f"exhausted-{asset_id}"
|
||||
return a
|
||||
|
||||
|
||||
def _fresh_asset(asset_id, duration=60.0):
|
||||
a = _make_asset(duration=duration, ranges=[])
|
||||
a.id = asset_id
|
||||
a.name = f"fresh-{asset_id}"
|
||||
return a
|
||||
|
||||
|
||||
class TestSmartMatchFiltersExhausted:
|
||||
def _call(self, assets):
|
||||
lib_repo = MagicMock()
|
||||
lib_repo.get.return_value = SimpleNamespace(project_id="proj-1")
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_by_library_and_file_type.return_value = assets
|
||||
project_repo = MagicMock()
|
||||
project = MagicMock()
|
||||
project.can_access.return_value = True
|
||||
project_repo.find_by_id.return_value = project
|
||||
|
||||
user = SimpleNamespace(id="user-1")
|
||||
auth_user = SimpleNamespace(user=user)
|
||||
|
||||
# storage_service 在 _to_asset_response 内 get_storage_service(),patch 掉
|
||||
import app.api.routes.assets as assets_module
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_download_url.return_value = "https://example.com/signed"
|
||||
original_get_storage = assets_module.get_storage_service
|
||||
assets_module.get_storage_service = lambda: svc
|
||||
try:
|
||||
resp = smart_match_assets(
|
||||
SmartMatchRequest(library_id="lib-1", kind="video"),
|
||||
authenticated_user=auth_user,
|
||||
asset_repository=asset_repo,
|
||||
asset_library_repository=lib_repo,
|
||||
project_repository=project_repo,
|
||||
)
|
||||
finally:
|
||||
assets_module.get_storage_service = original_get_storage
|
||||
return resp
|
||||
|
||||
def test_exhausted_assets_excluded(self):
|
||||
"""smart-match 结果中 usable=false 的素材被剔除,新鲜素材保留。"""
|
||||
assets = [
|
||||
_exhausted_asset("a-exhausted-1"),
|
||||
_exhausted_asset("a-exhausted-2"),
|
||||
_fresh_asset("a-fresh-1"),
|
||||
]
|
||||
resp = self._call(assets)
|
||||
returned_ids = {item.asset.id for item in resp.items}
|
||||
assert "a-fresh-1" in returned_ids
|
||||
assert "a-exhausted-1" not in returned_ids
|
||||
assert "a-exhausted-2" not in returned_ids
|
||||
# total_candidates 是过滤前的候选总数
|
||||
assert resp.total_candidates == 3
|
||||
# 返回的素材全部 usable=True
|
||||
assert all(item.asset.usable for item in resp.items)
|
||||
|
||||
def test_all_exhausted_returns_empty(self):
|
||||
"""全部素材已用尽时返回空列表(不报错,前端显示空结果)。"""
|
||||
assets = [_exhausted_asset("a-ex-1"), _exhausted_asset("a-ex-2")]
|
||||
resp = self._call(assets)
|
||||
assert resp.items == []
|
||||
assert resp.total_candidates == 2
|
||||
|
||||
def test_fresh_assets_all_returned(self):
|
||||
assets = [_fresh_asset("a-1"), _fresh_asset("a-2")]
|
||||
resp = self._call(assets)
|
||||
assert len(resp.items) == 2
|
||||
assert all(item.asset.usable for item in resp.items)
|
||||
@@ -0,0 +1,461 @@
|
||||
"""素材片段使用记录追踪 + 受控复用机制测试(asset_segment_tracker).
|
||||
|
||||
覆盖:
|
||||
- get_used_segments 聚合 metadata 中持久化的区间
|
||||
- record_used_segments 追加新记录(use_count=1,保留原有 metadata 字段)
|
||||
- record_used_segments 复用同一区间时累加 use_count / 刷新 last_used_at
|
||||
- remove_used_segment 匹配删除(tolerance + plan_id,旧数据按时间匹配)
|
||||
- reset_used_segments 清空(其他字段不动)
|
||||
- find_reusable_range:选最久未用且 use_count<3 的区间;全部达上限返回 None
|
||||
- make_reuse_callback:返回复用区间、累加 reused_tracker、DB 异常返回 None
|
||||
- _calc_random_start_time:100 次避不开时调用复用回调返回历史区间(不再清空历史)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from app.services import asset_segment_tracker as ast
|
||||
from app.services.asset_segment_tracker import (
|
||||
MAX_RANGE_USE_COUNT,
|
||||
REUSE_RATIO_LIMIT,
|
||||
SEGMENT_EDGE_GAP,
|
||||
find_reusable_range,
|
||||
get_used_segments,
|
||||
make_reuse_callback,
|
||||
record_used_segments,
|
||||
remove_used_segment,
|
||||
reset_used_segments,
|
||||
)
|
||||
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
|
||||
|
||||
class FakeModel:
|
||||
"""模拟 AssetModel:id + classification_result(JSON Text)+ updated_at。"""
|
||||
|
||||
def __init__(self, asset_id: str, meta: dict | None = None):
|
||||
self.id = asset_id
|
||||
self.classification_result = json.dumps(meta, ensure_ascii=False) if meta else None
|
||||
self.updated_at = None
|
||||
|
||||
def meta(self) -> dict:
|
||||
return json.loads(self.classification_result) if self.classification_result else {}
|
||||
|
||||
|
||||
class _InExpr:
|
||||
def __init__(self, ids, models):
|
||||
self._ids = ids
|
||||
self._models = models
|
||||
|
||||
def all(self):
|
||||
return [self._models[i] for i in self._ids if i in self._models]
|
||||
|
||||
|
||||
class _EqExpr:
|
||||
def __init__(self, target_id, models):
|
||||
self._target_id = target_id
|
||||
self._models = models
|
||||
|
||||
def with_for_update(self):
|
||||
# 模拟 SQLAlchemy Query.with_for_update() 链式返回自身
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._models.get(self._target_id)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""模拟 db:db.query(Model).filter(Model.id.in_(ids)).all() / .filter(Model.id == id).first()。"""
|
||||
|
||||
class _Col:
|
||||
def __init__(self, models):
|
||||
self._models = models
|
||||
|
||||
def in_(self, ids):
|
||||
return _InExpr(list(ids), self._models)
|
||||
|
||||
def __eq__(self, other):
|
||||
return _EqExpr(other, self._models)
|
||||
|
||||
def __init__(self, models: dict[str, FakeModel]):
|
||||
self._models = models
|
||||
self.commits = 0
|
||||
|
||||
def query(self, _model):
|
||||
col = self._Col(self._models)
|
||||
|
||||
class _Q:
|
||||
def filter(self_inner, expr):
|
||||
return expr
|
||||
|
||||
q = _Q()
|
||||
_model.id = col
|
||||
return q
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_model(monkeypatch):
|
||||
monkeypatch.setattr(ast, "AssetModel", FakeModel)
|
||||
|
||||
|
||||
def _db(models):
|
||||
return FakeSession(models)
|
||||
|
||||
|
||||
def _ranges(db, aid="a1"):
|
||||
model = db._models[aid]
|
||||
return json.loads(model.classification_result)["used_time_ranges"]
|
||||
|
||||
|
||||
# ── 配置常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_config_constants():
|
||||
assert MAX_RANGE_USE_COUNT == 3
|
||||
assert REUSE_RATIO_LIMIT == 0.15
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
|
||||
|
||||
# ── get_used_segments ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_used_segments_aggregates_ranges(patched_model):
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 1.0, "end": 5.0, "plan_id": "p1", "use_count": 2},
|
||||
{"start": 9.0, "end": 12.0, "plan_id": "p2"},
|
||||
]
|
||||
},
|
||||
),
|
||||
"a2": FakeModel("a2", {"other": 1}),
|
||||
"a3": FakeModel("a3"),
|
||||
}
|
||||
db = _db(models)
|
||||
assert get_used_segments(db, ["a1", "a2", "a3", "missing"]) == {"a1": [(1.0, 5.0), (9.0, 12.0)]}
|
||||
|
||||
|
||||
def test_get_used_segments_empty(patched_model):
|
||||
assert get_used_segments(_db({}), []) == {}
|
||||
|
||||
|
||||
# ── record_used_segments ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_record_appends_new_range_with_use_count_one(patched_model):
|
||||
models = {"a1": FakeModel("a1", {"generation_use_count": 48, "review_status": "pending_review"})}
|
||||
db = _db(models)
|
||||
record_used_segments(db, "a1", 12.5, 20.3, "plan-x")
|
||||
meta = json.loads(models["a1"].classification_result)
|
||||
assert meta["generation_use_count"] == 48
|
||||
assert meta["review_status"] == "pending_review"
|
||||
ranges = meta["used_time_ranges"]
|
||||
assert len(ranges) == 1
|
||||
assert ranges[0]["start"] == 12.5 and ranges[0]["end"] == 20.3
|
||||
assert ranges[0]["plan_id"] == "plan-x"
|
||||
assert ranges[0]["use_count"] == 1
|
||||
assert "created_at" in ranges[0] and "last_used_at" in ranges[0]
|
||||
assert db.commits == 0 # 不自行 commit
|
||||
|
||||
|
||||
def test_record_reuse_same_range_increments_use_count(patched_model):
|
||||
"""新片段与历史区间高度重叠(复用)→ 累加 use_count,不新增记录。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{
|
||||
"start": 10.0,
|
||||
"end": 20.0,
|
||||
"plan_id": "p1",
|
||||
"use_count": 1,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"last_used_at": "2026-01-01T00:00:00+00:00",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
# 同一起点复用(find_reusable_range 返回的就是历史区间起点)
|
||||
record_used_segments(db, "a1", 10.0, 20.0, "p2")
|
||||
ranges = _ranges(db)
|
||||
assert len(ranges) == 1
|
||||
assert ranges[0]["use_count"] == 2
|
||||
assert ranges[0]["last_used_at"] != "2026-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_record_distinct_range_appends(patched_model):
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 10.0, "end": 20.0, "plan_id": "p1", "use_count": 1},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
record_used_segments(db, "a1", 25.0, 35.0, "p2")
|
||||
ranges = _ranges(db)
|
||||
assert len(ranges) == 2
|
||||
assert ranges[1]["use_count"] == 1
|
||||
|
||||
|
||||
def test_record_missing_asset_no_raise(patched_model):
|
||||
db = _db({})
|
||||
record_used_segments(db, "ghost", 1.0, 2.0, "p") # 不抛异常
|
||||
|
||||
|
||||
# ── remove_used_segment ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_remove_matching_range(patched_model):
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 1.0, "end": 5.0, "plan_id": "p1"},
|
||||
{"start": 9.0, "end": 12.0, "plan_id": "p2"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="p1") is True
|
||||
assert len(_ranges(db)) == 1
|
||||
assert _ranges(db)[0]["start"] == 9.0
|
||||
|
||||
|
||||
def test_remove_plan_mismatch_keeps_range(patched_model):
|
||||
models = {"a1": FakeModel("a1", {"used_time_ranges": [{"start": 1.0, "end": 5.0, "plan_id": "p1"}]})}
|
||||
db = _db(models)
|
||||
assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="other") is False
|
||||
assert len(_ranges(db)) == 1
|
||||
|
||||
|
||||
def test_remove_legacy_range_without_plan_id(patched_model):
|
||||
"""旧数据记录缺 plan_id → 按时间匹配可删除。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 2.0, "end": 12.0, "created_at": "2026-01-01T00:00:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
assert remove_used_segment(db, "a1", 2.0, 12.0, plan_id="plan-new") is True
|
||||
assert _ranges(db) == []
|
||||
|
||||
|
||||
# ── reset_used_segments(仅运维/测试)─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reset_clears_ranges_keeps_other_fields(patched_model):
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"generation_use_count": 3,
|
||||
"used_time_ranges": [
|
||||
{"start": 1.0, "end": 5.0},
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
reset_used_segments(db, "a1")
|
||||
meta = json.loads(models["a1"].classification_result)
|
||||
assert meta["used_time_ranges"] == []
|
||||
assert meta["generation_use_count"] == 3
|
||||
|
||||
|
||||
# ── find_reusable_range:受控复用选择 ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_find_reusable_prefers_oldest_unused(patched_model):
|
||||
"""选 last_used_at 最老、use_count 未达上限的区间;能容纳 clip_duration。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 0.0, "end": 8.0, "use_count": 1, "last_used_at": "2026-08-01T00:00:00+00:00"},
|
||||
{
|
||||
"start": 10.0,
|
||||
"end": 20.0,
|
||||
"use_count": 1,
|
||||
"last_used_at": "2026-01-01T00:00:00+00:00",
|
||||
}, # 最久未用
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
result = find_reusable_range(db, "a1", clip_duration=5.0, asset_total=30.0)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert start == 10.0 and end == 15.0
|
||||
|
||||
|
||||
def test_find_reusable_excludes_max_use_count(patched_model):
|
||||
"""use_count 达到上限(3)的区间不再参与复用;全部达上限返回 None。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 0.0, "end": 10.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
assert find_reusable_range(db, "a1", 5.0, 30.0) is None
|
||||
|
||||
|
||||
def test_find_reusable_fourth_use_rejected(patched_model):
|
||||
"""同区间复用第 4 次被拒绝:use_count=2 的可复用,use_count=3 的不可复用。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-03-01T00:00:00"},
|
||||
{"start": 10.0, "end": 20.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
result = find_reusable_range(db, "a1", 5.0, 30.0)
|
||||
# 只能选 use_count=2 的区间(start=0),不能选 use_count=3 的(虽然它更老)
|
||||
assert result is not None and result[0] == 0.0
|
||||
|
||||
|
||||
def test_find_reusable_clamps_to_asset_bounds(patched_model):
|
||||
"""历史区间起点 + clip_duration 会越素材末尾时,起点钳制到 max_start。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 25.0, "end": 30.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
result = find_reusable_range(db, "a1", clip_duration=10.0, asset_total=30.0)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert end <= 30.0 + 1e-6 and start >= 0.0
|
||||
|
||||
|
||||
def test_find_reusable_no_ranges_returns_none(patched_model):
|
||||
models = {"a1": FakeModel("a1", {"other": 1})}
|
||||
db = _db(models)
|
||||
assert find_reusable_range(db, "a1", 5.0, 30.0) is None
|
||||
|
||||
|
||||
# ── make_reuse_callback ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reuse_callback_returns_range_and_tracks_duration(patched_model):
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 10.0, "end": 20.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
reused: dict[str, float] = {}
|
||||
cb = make_reuse_callback(db, {"a1": 30.0}, reused)
|
||||
result = cb("a1", 8.0)
|
||||
assert result is not None and result[0] == 10.0
|
||||
assert reused["a1"] == 8.0 # 复用时长累加
|
||||
|
||||
|
||||
def test_reuse_callback_db_error_returns_none(patched_model):
|
||||
class BoomSession:
|
||||
def query(self, _m):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
reused: dict[str, float] = {}
|
||||
cb = make_reuse_callback(BoomSession(), {"a1": 30.0}, reused)
|
||||
assert cb("a1", 8.0) is None # 异常被吞,返回 None
|
||||
assert reused == {}
|
||||
|
||||
|
||||
# ── _calc_random_start_time 与受控回调集成 ────────────────────────────────────
|
||||
|
||||
|
||||
def test_calc_random_start_uses_reuse_callback_when_exhausted(monkeypatch):
|
||||
"""素材区间被占满、100 次随机找不到空位时,调用复用回调返回历史区间。"""
|
||||
import packages.domain.plan_generator_utils as pgu
|
||||
|
||||
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5) # 固定候选点必撞区间
|
||||
|
||||
durations = {"a1": 30.0}
|
||||
used = {"a1": [(0.0, 30.0)]} # 全占满
|
||||
calls = []
|
||||
|
||||
def reuse_cb(asset_id, clip_duration):
|
||||
calls.append((asset_id, clip_duration))
|
||||
return (10.0, 18.0)
|
||||
|
||||
result = _calc_random_start_time("a1", 8.0, durations, used, on_exhausted=reuse_cb)
|
||||
assert calls == [("a1", 8.0)]
|
||||
assert result == 10.0
|
||||
|
||||
|
||||
def test_calc_random_start_reuse_callback_none_returns_none(monkeypatch):
|
||||
"""复用回调返回 None(区间全部达上限/复用占比超闸门)→ calc 返回 None。
|
||||
|
||||
新机制下不做末尾/0.0 重叠降级(那会把片段放回已用过的画面),
|
||||
由调用方轮询下一个素材或报 400;历史记录不被清空。
|
||||
"""
|
||||
import packages.domain.plan_generator_utils as pgu
|
||||
|
||||
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5)
|
||||
|
||||
durations = {"a1": 30.0}
|
||||
used = {"a1": [(0.0, 30.0)]}
|
||||
used_before = list(used["a1"])
|
||||
result = _calc_random_start_time("a1", 8.0, durations, used, on_exhausted=lambda aid, d: None)
|
||||
assert result is None
|
||||
assert used["a1"] == used_before # 历史记录未被清空
|
||||
|
||||
|
||||
def test_calc_random_start_no_callback_backward_compatible(monkeypatch):
|
||||
"""不传 on_exhausted 时行为与旧版兼容(100 次失败走降级)。"""
|
||||
import packages.domain.plan_generator_utils as pgu
|
||||
|
||||
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5)
|
||||
result = _calc_random_start_time("a1", 8.0, {"a1": 30.0}, {"a1": [(0.0, 30.0)]})
|
||||
assert result is not None
|
||||
@@ -97,26 +97,6 @@ class TestSelectAssetsAllMode:
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSelectAssetsRandomMode:
|
||||
"""random 模式:随机选取 N 个。"""
|
||||
|
||||
def test_random_selects_exact_count(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(10)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=3)
|
||||
assert len(result) == 3
|
||||
assert all(rid in [a.id for a in assets] for rid in result)
|
||||
|
||||
def test_random_count_zero_returns_all(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(5)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=0)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_random_count_exceeds_total_returns_all(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(3)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=100)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestSelectAssetsSmartMode:
|
||||
"""smart 模式:使用 smart_match 多维评分(质量40%+时长30%+新鲜度20%+未使用10%)。"""
|
||||
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
"""AI Review 回归:批量生成 count>1 但 source_edit_plan_id 为空时不应 IndexError。
|
||||
|
||||
变体 plan 预克隆仅在 source_edit_plan_id 非空时执行;无源 plan 时
|
||||
variant_plan_ids 为空,循环中禁止索引访问,各任务走自身随机选片流程。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def _make_user():
|
||||
return SimpleNamespace(user=SimpleNamespace(id="user-1"))
|
||||
|
||||
|
||||
def _make_request(count):
|
||||
from app.schemas.generation_task import CreateGenerationTaskRequest
|
||||
|
||||
return CreateGenerationTaskRequest(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="one_take",
|
||||
asset_ids=["a1"],
|
||||
count=count,
|
||||
source_edit_plan_id="", # 关键:无源 plan(空字符串为假值)
|
||||
)
|
||||
|
||||
|
||||
class TestBatchNoSourcePlanNoIndexError:
|
||||
def test_count3_without_source_plan_creates_three_tasks(self):
|
||||
"""count=3 且无 source_edit_plan_id:不克隆、不 IndexError、创建 3 个任务。"""
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
repo = MagicMock()
|
||||
repo.count_pending_by_user.return_value = 0
|
||||
repo.count_pending_total.return_value = 0
|
||||
repo.create.side_effect = lambda t: t
|
||||
repo.update.side_effect = lambda t: t
|
||||
|
||||
created = []
|
||||
|
||||
def _fake_execute(cmd):
|
||||
task = MagicMock()
|
||||
task.id = f"task-{len(created) + 1}"
|
||||
task.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
task.status = "pending"
|
||||
task.progress = 0.0
|
||||
task.strategy_id = "one_take"
|
||||
task.error_message = ""
|
||||
task.cover_url = None
|
||||
task.title_config = {}
|
||||
task.created_at = None
|
||||
task.batch_id = "batch-1"
|
||||
created.append(task)
|
||||
return task
|
||||
|
||||
with patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = _fake_execute
|
||||
with patch(
|
||||
"app.api.routes.generation_tasks.safe_enqueue_generation_task",
|
||||
return_value=True,
|
||||
):
|
||||
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
|
||||
with patch(
|
||||
"app.api.routes.generation_tasks._resolve_project_and_library",
|
||||
return_value=("proj-1", ""),
|
||||
):
|
||||
# 核心断言:不得抛 IndexError(变体 plan 索引守卫)。
|
||||
# 响应序列化字段与本回归无关,ValidationError 可接受,
|
||||
# 但 IndexError 必须不出现。
|
||||
try:
|
||||
create_generation_task(
|
||||
_make_request(3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=MagicMock(),
|
||||
)
|
||||
except IndexError as exc: # pragma: no cover - 不应发生
|
||||
pytest.fail(f"无源 plan 批量生成触发 IndexError: {exc}")
|
||||
except Exception:
|
||||
# 响应序列化等其他异常与本次守卫无关,忽略
|
||||
pass
|
||||
|
||||
# 3 个任务全部创建(未因 IndexError 中断)
|
||||
assert len(created) == 3
|
||||
# 无源 plan 时所有任务 source_edit_plan_id 均为空
|
||||
assert all(not t.source_edit_plan_id for t in created)
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
"""clone_plan_for_variant 单元测试(Task G 验收项:批量 N 条视频片段独立)。
|
||||
|
||||
验证:
|
||||
- 同一源 plan 克隆 3 次产出 3 个不同 plan_id,各自片段起点不同
|
||||
- 源 plan 的片段不被修改
|
||||
- 模板/config/时长结构被复制
|
||||
- 复用占比闸门触发时保留原起点(不重复抽取)
|
||||
- 源 plan 无片段时抛出 ValueError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit,便于复用同目录 stub
|
||||
|
||||
# 复用 test_edit_plan_service 里的内存 stub 仓储
|
||||
from test_edit_plan_service import ( # noqa: E402
|
||||
StubEditPlanClipRepository,
|
||||
StubEditPlanRepository,
|
||||
_make_service,
|
||||
)
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc_with_source():
|
||||
"""构造带源 plan + 3 个片段的 service(stub 仓储)。"""
|
||||
svc = _make_service()
|
||||
# clone 用 self._clip_repo.session 拿 db;stub 无 session,补一个 MagicMock
|
||||
svc._clip_repo.session = MagicMock()
|
||||
|
||||
source = svc.create_plan(template_id="tpl-001", name="源计划", total_duration=15.0)
|
||||
|
||||
for i in range(3):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=source.id,
|
||||
clip_type="main",
|
||||
order=i,
|
||||
asset_id=f"a{i % 2 + 1}", # a1, a2, a1
|
||||
start_time=float(i * 5),
|
||||
duration=5.0,
|
||||
)
|
||||
svc._clip_repo.create(clip)
|
||||
return svc, source
|
||||
|
||||
|
||||
def _clone_with_fake_calc(svc, source, starts, *, used=None):
|
||||
"""用受控的 calc 起点列表执行一次克隆。
|
||||
|
||||
starts: 每次 _calc_random_start_time 返回的起点(按片段顺序)。
|
||||
返回 (new_plan, replace_all 调用的 clips_data, calc 调用记录)。
|
||||
"""
|
||||
calc_calls: list[dict] = []
|
||||
|
||||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||||
idx = len(calc_calls)
|
||||
calc_calls.append({"asset_id": asset_id, "clip_duration": clip_duration, "on_exhausted": on_exhausted})
|
||||
return starts[idx]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.edit_plan_service.get_used_segments",
|
||||
return_value=used or {},
|
||||
),
|
||||
patch(
|
||||
"app.services.edit_plan_service.make_reuse_callback",
|
||||
return_value=lambda aid, d: None,
|
||||
),
|
||||
patch(
|
||||
"app.services.edit_plan_service.record_used_segments",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"packages.domain.plan_generator_utils._calc_random_start_time",
|
||||
side_effect=fake_calc,
|
||||
),
|
||||
patch.object(svc, "replace_all_clips_transactional", return_value=3) as mock_replace,
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.models.AssetModel",
|
||||
create=True,
|
||||
) as mock_asset_model,
|
||||
):
|
||||
# db.query(AssetModel).filter(...).all() → 返回带 duration 的 mock 素材
|
||||
m1 = MagicMock(id="a1")
|
||||
m1.duration = 60.0
|
||||
m2 = MagicMock(id="a2")
|
||||
m2.duration = 60.0
|
||||
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
new_plan = svc.clone_plan_for_variant(source.id, created_by_user_id="u1", name_suffix="变体")
|
||||
clips_data = mock_replace.call_args.args[1]
|
||||
return new_plan, clips_data, calc_calls
|
||||
|
||||
|
||||
class TestClonePlanForVariant:
|
||||
def test_three_clones_produce_distinct_plans_and_starts(self, svc_with_source):
|
||||
"""克隆 3 次:3 个不同 plan_id,片段起点互不相同(Task G 验收)。"""
|
||||
svc, source = svc_with_source
|
||||
start_sets = [
|
||||
[10.0, 20.0, 30.0],
|
||||
[11.0, 21.0, 31.0],
|
||||
[12.0, 22.0, 32.0],
|
||||
]
|
||||
plans = []
|
||||
all_clips = []
|
||||
for starts in start_sets:
|
||||
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, starts)
|
||||
plans.append(new_plan)
|
||||
all_clips.append(clips_data)
|
||||
|
||||
# 3 个不同 plan_id,且都不等于源 plan
|
||||
plan_ids = {p.id for p in plans}
|
||||
assert len(plan_ids) == 3
|
||||
assert source.id not in plan_ids
|
||||
|
||||
# 每次克隆的起点各自不同
|
||||
for clips_data, starts in zip(all_clips, start_sets, strict=True):
|
||||
assert [c["start_time"] for c in clips_data] == starts
|
||||
|
||||
# 三次克隆的起点集合互不相同
|
||||
assert {tuple(c["start_time"] for c in clips) for clips in all_clips} == {
|
||||
(10.0, 20.0, 30.0),
|
||||
(11.0, 21.0, 31.0),
|
||||
(12.0, 22.0, 32.0),
|
||||
}
|
||||
|
||||
def test_source_plan_not_modified(self, svc_with_source):
|
||||
"""克隆不修改源 plan 及其片段(保留用户手动编辑)。"""
|
||||
svc, source = svc_with_source
|
||||
source_clips_before = sorted(
|
||||
[(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)]
|
||||
)
|
||||
source_name_before = source.name
|
||||
|
||||
_clone_with_fake_calc(svc, source, [9.0, 19.0, 29.0])
|
||||
_clone_with_fake_calc(svc, source, [8.0, 18.0, 28.0])
|
||||
|
||||
source_clips_after = sorted(
|
||||
[(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)]
|
||||
)
|
||||
assert source_clips_after == source_clips_before
|
||||
assert svc._plan_repo.get(source.id).name == source_name_before
|
||||
|
||||
def test_clone_copies_structure(self, svc_with_source):
|
||||
"""克隆复制 template_id / config / total_duration / 片段素材与时长。"""
|
||||
svc, source = svc_with_source
|
||||
source.config = {"mode": "ONE_TAKE"}
|
||||
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0])
|
||||
|
||||
assert new_plan.template_id == source.template_id
|
||||
assert new_plan.total_duration == source.total_duration
|
||||
assert new_plan.config == {"mode": "ONE_TAKE"}
|
||||
assert "变体" in new_plan.name
|
||||
# 片段素材与时长结构保持
|
||||
assert [c["asset_id"] for c in clips_data] == ["a1", "a2", "a1"]
|
||||
assert all(c["duration"] == 5.0 for c in clips_data)
|
||||
assert [c["order"] for c in clips_data] == [0, 1, 2]
|
||||
|
||||
def test_clone_uses_reuse_callback(self, svc_with_source):
|
||||
"""克隆时 calc 传入了 on_exhausted 受控复用回调(耗尽时复用而非清空历史)。"""
|
||||
svc, source = svc_with_source
|
||||
_, _, calc_calls = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0])
|
||||
assert len(calc_calls) == 3
|
||||
for call in calc_calls:
|
||||
assert call["on_exhausted"] is not None
|
||||
|
||||
def test_clone_ratio_blocked_keeps_original_start(self, svc_with_source):
|
||||
"""复用占比闸门触发(calc 返回 None)时保留源片段原起点。"""
|
||||
svc, source = svc_with_source
|
||||
# 第 3 个片段 calc 返回 None(模拟复用占比超 15% 拒绝复用)
|
||||
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, None]) # type: ignore[list-item]
|
||||
starts = [c["start_time"] for c in clips_data]
|
||||
assert starts[0] == 10.0
|
||||
assert starts[1] == 20.0
|
||||
# 第 3 片段保留源起点(源 order=2 → start_time=10.0)
|
||||
assert starts[2] == 10.0
|
||||
|
||||
def test_clone_empty_source_raises(self):
|
||||
"""源 plan 无片段时抛出 ValueError。"""
|
||||
svc = _make_service()
|
||||
svc._clip_repo.session = MagicMock()
|
||||
empty = svc.create_plan(template_id="tpl-x", name="空计划")
|
||||
with pytest.raises(ValueError, match="无片段"):
|
||||
svc.clone_plan_for_variant(empty.id, name_suffix="变体")
|
||||
|
||||
def test_clone_nonexistent_source_raises(self):
|
||||
"""源 plan 不存在时抛出 ValueError。"""
|
||||
svc = _make_service()
|
||||
svc._clip_repo.session = MagicMock()
|
||||
with pytest.raises(ValueError, match="不存在"):
|
||||
svc.clone_plan_for_variant("no-such-plan", name_suffix="变体")
|
||||
@@ -5,7 +5,7 @@
|
||||
- 素材不足时同一素材轮询切多个片段
|
||||
- 随机 start_time + used_segments 去重
|
||||
- 素材时长不足时 clip duration 缩短
|
||||
- 素材时长为 0 时抛 400
|
||||
- 素材时长全部为 0/缺失时抛 400「素材可切区间不足」;混合池中零时长素材被跳过
|
||||
- 使用 replace_all_clips_transactional 原子性替换
|
||||
- order 从 0 开始
|
||||
- start_time=None 时抛出 400
|
||||
@@ -81,6 +81,34 @@ def _get_clips_data_from_call(mock_plan_svc):
|
||||
return call_args.kwargs.get("clips_data", [])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_segment_tracker():
|
||||
"""from-assets 现在会读/写素材 metadata 的片段区间记录,测试中 mock 掉避免依赖真实 DB。
|
||||
|
||||
get_used_segments 返回空 dict(等价历史行为:无历史区间);
|
||||
record/remove/reset 回调均无副作用。
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.get_used_segments",
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.record_used_segments",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.make_reuse_callback",
|
||||
return_value=lambda asset_id, clip_duration: None,
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.remove_used_segment",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestEditorClipsBySegments:
|
||||
"""测试按 segment 数量创建片段 + 素材轮询。"""
|
||||
|
||||
@@ -248,7 +276,11 @@ class TestEditorClipsDurationAndStartTime:
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_zero_duration_asset_raises_400(self, mock_storage):
|
||||
"""素材时长为 0 时应抛出 400,而不是创建无效片段。"""
|
||||
"""所有素材时长均为 0 时轮询无可用素材,抛出 400「素材可切区间不足」。
|
||||
|
||||
新轮询逻辑下零时长素材被跳过(而非立即报错);全部素材都被跳过时
|
||||
返回 400,不创建无效片段。
|
||||
"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
@@ -274,7 +306,49 @@ class TestEditorClipsDurationAndStartTime:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "时长" in exc_info.value.detail
|
||||
assert "素材可切区间不足" in exc_info.value.detail
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
|
||||
"""素材池混合零时长与正常素材时,零时长素材被跳过、正常素材承担片段。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(
|
||||
side_effect=lambda aid: {
|
||||
"zero": _make_mock_asset("zero", 0.0),
|
||||
"good": _make_mock_asset("good", 30.0),
|
||||
}[aid]
|
||||
)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["zero", "good"], required_clips_count=2)
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(2)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
side_effect=[5.0, 12.0],
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 2
|
||||
# 所有片段都分配给正常素材,零时长素材被跳过
|
||||
assert all(c["asset_id"] == "good" for c in clips_data)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_missing_duration_asset_raises_400(self, mock_storage):
|
||||
@@ -387,7 +461,7 @@ class TestEditorClipsDurationAndStartTime:
|
||||
|
||||
captured_used_segments = []
|
||||
|
||||
def fake_calc(asset_id, clip_duration, asset_durations, used_segments):
|
||||
def fake_calc(asset_id, clip_duration, asset_durations, used_segments, on_exhausted=None):
|
||||
captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
|
||||
return (len(captured_used_segments) - 1) * 5.0
|
||||
|
||||
@@ -422,7 +496,7 @@ class TestEditorClipsErrorHandling:
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_none_start_time_raises_400(self, mock_storage):
|
||||
"""_calc_random_start_time 返回 None 时应抛出 HTTPException 400。"""
|
||||
"""所有素材 calc 均返回 None(区间耗尽且复用被拒)→ 轮询失败抛 400。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
@@ -430,7 +504,7 @@ class TestEditorClipsErrorHandling:
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
# 素材有 duration 但 random 返回 None
|
||||
# 素材有 duration 但 calc 返回 None(模拟可用区间耗尽、复用被闸门拒绝)
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
@@ -455,7 +529,9 @@ class TestEditorClipsErrorHandling:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "时长" in exc_info.value.detail
|
||||
assert "素材可切区间不足" in exc_info.value.detail
|
||||
# 复用被拒导致无起点时,不应创建任何片段
|
||||
assert not mock_plan_svc.replace_all_clips_transactional.called
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_transactional_replace_exception_propagates(self, mock_storage):
|
||||
@@ -484,3 +560,190 @@ class TestEditorClipsErrorHandling:
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
|
||||
class TestReuseRatioGate:
|
||||
"""素材区间耗尽后的受控复用与 15% 占比闸门(路由级)。"""
|
||||
|
||||
@staticmethod
|
||||
def _make_calc_with_reuse(normal_starts, reused_durations):
|
||||
"""构造模拟「区间耗尽后受控复用」的 _calc_random_start_time。
|
||||
|
||||
normal_starts: list[float | None],前 N 次调用返回的空闲起点;
|
||||
返回 None 表示随机找不到空闲 → 触发 on_exhausted 复用回调。
|
||||
回调被调用时返回复用区间(固定 0.0 起点),复用片段时长由路由累加到
|
||||
reused_durations;回调内部占比预判超 15% 时返回 None(calc 随之 None)。
|
||||
"""
|
||||
calls = {"i": 0}
|
||||
|
||||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||||
i = calls["i"]
|
||||
calls["i"] += 1
|
||||
if i < len(normal_starts) and normal_starts[i] is not None:
|
||||
return normal_starts[i]
|
||||
# 空闲耗尽 → 走受控复用回调(回调返回 (start, end) 元组,calc 取起点)
|
||||
if on_exhausted is not None:
|
||||
result = on_exhausted(asset_id, clip_duration)
|
||||
return result[0] if result else None
|
||||
return None
|
||||
|
||||
return fake_calc, calls
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_reused_clip_ratio_within_threshold(self, mock_storage):
|
||||
"""素材 60s、片段 5s:前 12 个用空闲区间,第 13 个复用,
|
||||
复用占比 5/(12*5+5)=7.7% ≤ 15%,正常创建 13 个片段。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=13)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=13)
|
||||
|
||||
reused: dict = {}
|
||||
|
||||
def reuse_cb(aid, dur):
|
||||
# 模拟真实回调:返回复用区间前记录复用时长
|
||||
reused[aid] = reused.get(aid, 0.0) + dur
|
||||
return (0.0, dur)
|
||||
|
||||
# 前 12 次分配空闲起点;第 13 次 calc 直接走回调(normal_starts 越界 → None → 回调)
|
||||
normal_starts = [float(i * 5) for i in range(12)]
|
||||
fake_calc, _ = self._make_calc_with_reuse(normal_starts, reused)
|
||||
with (
|
||||
_patch_segments(_segments(13, dur_min=5.0, dur_max=5.0)),
|
||||
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
|
||||
patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=reuse_cb),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 13
|
||||
# 1 个复用片段,占比 1/13 ≈ 7.7% ≤ 15%
|
||||
assert reused.get("a1", 0.0) == 5.0
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
|
||||
"""复用占比将超 15% 时回调拒绝复用 → 无可用素材 → 400「素材可切区间不足」。
|
||||
|
||||
60s 素材、5s 片段:前 12 个空闲、随后复用占比累计;当 (reused+d)/(assigned+d)
|
||||
超过 15% 时回调返回 None,calc 返回 None,轮询无素材 → 400。
|
||||
"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=0)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=20)
|
||||
|
||||
# 模拟真实回调:累计复用时长,预判超 15% 拒绝
|
||||
reused: dict = {}
|
||||
assigned: dict = {}
|
||||
|
||||
def fake_reuse_cb(aid, clip_duration):
|
||||
a = assigned.get(aid, 0.0)
|
||||
r = reused.get(aid, 0.0)
|
||||
if a > 0 and (r + clip_duration) / (a + clip_duration) > 0.15:
|
||||
return None # 占比闸门拒绝
|
||||
reused[aid] = r + clip_duration
|
||||
return (0.0, clip_duration)
|
||||
|
||||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||||
a = assigned.get(asset_id, 0.0)
|
||||
# 前 12 个片段(60s/5s)有空闲区间
|
||||
if a < 60.0:
|
||||
start = a
|
||||
assigned[asset_id] = a + clip_duration
|
||||
return start
|
||||
# 之后空闲耗尽 → 复用
|
||||
if on_exhausted is not None:
|
||||
result = on_exhausted(asset_id, clip_duration)
|
||||
if result is not None:
|
||||
assigned[asset_id] = assigned.get(asset_id, 0.0) + clip_duration
|
||||
return result[0] if result else None
|
||||
return None
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(20, dur_min=5.0, dur_max=5.0)),
|
||||
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
|
||||
patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=fake_reuse_cb),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "素材可切区间不足" in exc_info.value.detail
|
||||
# 闸门在复用占比达上限时拒绝:60s 空闲 + 至多 ~15% 复用
|
||||
assert reused.get("a1", 0.0) <= 12.0 # 10.0 或 15.0 以内,不会无限复用
|
||||
# 未创建任何片段(整批失败)
|
||||
assert not mock_plan_svc.replace_all_clips_transactional.called
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_calc_none_falls_through_to_next_asset(self, mock_storage):
|
||||
"""一个素材区间耗尽且复用被拒(calc 返回 None)时,轮询到下一个可用素材。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(
|
||||
side_effect=lambda aid: {
|
||||
"exhausted": _make_mock_asset("exhausted", 60.0),
|
||||
"fresh": _make_mock_asset("fresh", 60.0),
|
||||
}[aid]
|
||||
)
|
||||
body = ClipsFromAssetsRequest(asset_ids=["exhausted", "fresh"], required_clips_count=2)
|
||||
|
||||
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
|
||||
if asset_id == "exhausted":
|
||||
# 空闲耗尽 + 回调拒绝 → None
|
||||
return on_exhausted(asset_id, clip_duration) if on_exhausted else None
|
||||
return 8.0 # 新鲜素材正常返回
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(2, dur_min=5.0, dur_max=5.0)),
|
||||
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.make_reuse_callback",
|
||||
return_value=lambda aid, d: None, # 复用始终被拒
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 2
|
||||
# 耗尽素材被跳过,两个片段都分配给新鲜素材
|
||||
assert all(c["asset_id"] == "fresh" for c in clips_data)
|
||||
|
||||
@@ -612,6 +612,8 @@ def _make_task(
|
||||
task.extra_meta = extra_meta or {}
|
||||
task.asset_ids = asset_ids or []
|
||||
task.created_by_user_id = "test_user_001"
|
||||
# 默认无关联编辑计划:涉及克隆变体的测试自行设置并 mock EditPlanService
|
||||
task.source_edit_plan_id = None
|
||||
return task
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Ingest jobs GET route — 查不到 job_id 应返回 404,不是 500。
|
||||
|
||||
覆盖:
|
||||
- job 不存在:GET /ingest-jobs/{job_id} → 404
|
||||
- job 存在:200 + 序列化字段完整
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.ingest_jobs import get_ingest_job, router # noqa: E402
|
||||
from app.dependencies import get_ingest_job_repository # noqa: E402
|
||||
|
||||
from packages.domain import IngestJob, IngestJobStatus # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_job():
|
||||
return IngestJob(
|
||||
id="job-abc",
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
storage_key="key/test.mp4",
|
||||
status=IngestJobStatus.COMPLETED,
|
||||
error_message="",
|
||||
result_asset_id="asset-xyz",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_not_found():
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/ingest-jobs")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
app.dependency_overrides[get_ingest_job_repository] = lambda: mock_repo
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_found(fake_job):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/ingest-jobs")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = fake_job
|
||||
|
||||
app.dependency_overrides[get_ingest_job_repository] = lambda: mock_repo
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_get_ingest_job_not_found_returns_404(client_not_found):
|
||||
"""job 不存在时应返回 404,而不是 500 ValueError。"""
|
||||
resp = client_not_found.get("/ingest-jobs/no-such-job")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "no-such-job" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_get_ingest_job_found_returns_200(client_found, fake_job):
|
||||
"""job 存在时正常 200 + 字段完整。"""
|
||||
resp = client_found.get(f"/ingest-jobs/{fake_job.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["id"] == fake_job.id
|
||||
assert body["project_id"] == fake_job.project_id
|
||||
assert body["storage_key"] == fake_job.storage_key
|
||||
assert body["status"] == IngestJobStatus.COMPLETED.value
|
||||
assert body["result_asset_id"] == fake_job.result_asset_id
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
"""MediaKit 智能选片挪点冲突检测测试(Task G 验收项 1)。
|
||||
|
||||
覆盖 _recommended_time_conflicts:
|
||||
- 区间重叠判定(含 0.3s 边缘间隙扩边)
|
||||
- 不冲突场景(间隔大于边缘间隙)
|
||||
- 边缘间隙可配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
SEGMENT_EDGE_GAP,
|
||||
_recommended_time_conflicts,
|
||||
)
|
||||
|
||||
|
||||
class TestRecommendedTimeConflicts:
|
||||
def test_overlapping_range_conflicts(self):
|
||||
"""推荐区间与已用区间直接重叠 → 冲突。"""
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(12.0, 17.0)]) is True
|
||||
|
||||
def test_identical_range_conflicts(self):
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(10.0, 15.0)]) is True
|
||||
|
||||
def test_touching_endpoint_conflicts_due_to_edge_gap(self):
|
||||
"""首尾紧贴(推荐 15 开始,已用 [10,15]):0.3s 扩边内 → 冲突。"""
|
||||
assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 15.0)]) is True
|
||||
|
||||
def test_gap_within_edge_gap_conflicts(self):
|
||||
"""间隔 0.2s(< 0.3s 边缘间隙)→ 冲突。"""
|
||||
assert _recommended_time_conflicts(15.2, 5.0, [(10.0, 15.0)]) is True
|
||||
|
||||
def test_gap_beyond_edge_gap_no_conflict(self):
|
||||
"""间隔 0.5s(> 0.3s 边缘间隙)→ 不冲突。"""
|
||||
assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)]) is False
|
||||
|
||||
def test_far_apart_no_conflict(self):
|
||||
"""相隔很远 → 不冲突。"""
|
||||
assert _recommended_time_conflicts(20.0, 5.0, [(0.0, 5.0)]) is False
|
||||
|
||||
def test_empty_used_no_conflict(self):
|
||||
assert _recommended_time_conflicts(10.0, 5.0, []) is False
|
||||
|
||||
def test_any_one_range_conflicts(self):
|
||||
"""多个已用区间,任一冲突即返回 True。"""
|
||||
used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)]
|
||||
assert _recommended_time_conflicts(12.0, 2.0, used) is True
|
||||
assert _recommended_time_conflicts(6.0, 2.0, used) is False
|
||||
|
||||
def test_custom_edge_gap(self):
|
||||
"""edge_gap 可配置:gap=0 时紧贴不冲突(端点相接不算重叠)。"""
|
||||
# edge_gap=0:15.0 开始与已用 [10,15] 端点相接,区间判定 start<end_gap(15) → False
|
||||
assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 15.0)], edge_gap=0.0) is False
|
||||
# edge_gap=1.0:0.5 间隔也算冲突
|
||||
assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)], edge_gap=1.0) is True
|
||||
|
||||
def test_default_edge_gap_constant(self):
|
||||
"""默认边缘间隙常量为 0.3s(配置常量)。"""
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
@@ -69,8 +69,13 @@ class TestRecommendedTimeConflicts:
|
||||
def test_conflict_exact_boundary_no_overlap(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [10, 15],已用 [0, 10] — 边界相接不算冲突
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)]) is False
|
||||
# 新语义:默认 0.3s 边缘间隙扩边,推荐 [10, 15] 与已用 [0, 10] 首尾相接
|
||||
# 落在扩边范围内 → 判为冲突(避免观感重复)
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)]) is True
|
||||
# 显式 edge_gap=0 时退回纯区间重叠判定:相接不算重叠
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)], edge_gap=0.0) is False
|
||||
# 间隙大于边缘间隙(0.5 > 0.3)→ 不冲突
|
||||
assert _recommended_time_conflicts(10.5, 5.0, [(0.0, 10.0)]) is False
|
||||
|
||||
def test_conflict_multiple_used(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
@@ -78,8 +83,10 @@ class TestRecommendedTimeConflicts:
|
||||
used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)]
|
||||
# 推荐 [6, 11] 与 [10, 15] 冲突
|
||||
assert _recommended_time_conflicts(6.0, 5.0, used) is True
|
||||
# 推荐 [15, 20] 不冲突
|
||||
assert _recommended_time_conflicts(15.0, 5.0, used) is False
|
||||
# 推荐 [15, 20] 与 [10, 15] 首尾相接:0.3s 扩边内 → 冲突
|
||||
assert _recommended_time_conflicts(15.0, 5.0, used) is True
|
||||
# 空闲段 [5.3, 9.7] 长 4.4s:推荐 [5.5, 9.5](dur=4)与三区间扩边均不接触
|
||||
assert _recommended_time_conflicts(5.5, 4.0, used) is False
|
||||
|
||||
|
||||
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -180,17 +181,19 @@ class TestPreviewEditPlanAutoAssociation:
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
|
||||
return_value=fake_plan_repo,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/generation/preview",
|
||||
json=_make_request_body(source_edit_plan_id=""),
|
||||
)
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = SimpleNamespace(id="plan-clone-001")
|
||||
resp = client.post(
|
||||
"/api/v1/generation/preview",
|
||||
json=_make_request_body(source_edit_plan_id=""),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置
|
||||
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置(自动关联后再克隆为独立 plan)
|
||||
tasks = list(gen_task_repo._store.values())
|
||||
assert len(tasks) == 1
|
||||
task = tasks[0]
|
||||
assert task.source_edit_plan_id == "plan-auto-001"
|
||||
assert task.source_edit_plan_id == "plan-clone-001"
|
||||
|
||||
@patch(
|
||||
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
|
||||
@@ -209,16 +212,18 @@ class TestPreviewEditPlanAutoAssociation:
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
):
|
||||
"""前端已传 source_edit_plan_id 时,不应触发自动关联"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/preview",
|
||||
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
|
||||
)
|
||||
"""前端已传 source_edit_plan_id 时,不触发自动关联,但仍克隆独立变体 plan"""
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = SimpleNamespace(id="plan-clone-explicit")
|
||||
resp = client.post(
|
||||
"/api/v1/generation/preview",
|
||||
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
tasks = list(gen_task_repo._store.values())
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0].source_edit_plan_id == "plan-explicit-001"
|
||||
assert tasks[0].source_edit_plan_id == "plan-clone-explicit"
|
||||
|
||||
@patch(
|
||||
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
|
||||
|
||||
@@ -116,7 +116,7 @@ class TestGenerationTaskCreate:
|
||||
voice_ids=["v1"],
|
||||
created_by_user_id="u1",
|
||||
source_edit_plan_id="ep1",
|
||||
asset_select_mode="random",
|
||||
asset_select_mode="smart",
|
||||
batch_id="batch_001",
|
||||
video_title="测试视频",
|
||||
resolution="1080p",
|
||||
@@ -132,7 +132,7 @@ class TestGenerationTaskCreate:
|
||||
assert task.voice_ids == ["v1"]
|
||||
assert task.created_by_user_id == "u1"
|
||||
assert task.source_edit_plan_id == "ep1"
|
||||
assert task.asset_select_mode == "random"
|
||||
assert task.asset_select_mode == "smart"
|
||||
assert task.batch_id == "batch_001"
|
||||
assert task.video_title == "测试视频"
|
||||
assert task.resolution == "1080p"
|
||||
|
||||
@@ -17,3 +17,48 @@ def test_infer_mime_type_treats_common_video_extensions_as_video():
|
||||
|
||||
def test_infer_mime_type_keeps_unknown_files_as_image_placeholder():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/photo.jpg") == "image/jpeg"
|
||||
|
||||
|
||||
# ─── Audio extensions ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_m4a():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/voice.m4a") == "audio/mp4"
|
||||
# case-insensitive
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/VOICE.M4A") == "audio/mp4"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_mp3():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/song.mp3") == "audio/mpeg"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_wav():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/recording.wav") == "audio/wav"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_flac():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/hifi.flac") == "audio/flac"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_ogg():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/podcast.ogg") == "audio/ogg"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_aac():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/stream.aac") == "audio/aac"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_wma():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/old.wma") == "audio/x-ms-wma"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_amr():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/voice_msg.amr") == "audio/amr"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_opus():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/voip.opus") == "audio/opus"
|
||||
|
||||
|
||||
def test_infer_mime_type_audio_weba():
|
||||
assert infer_mime_type_from_storage_key("uploads/abc/web_audio.weba") == "audio/webm"
|
||||
|
||||
Reference in New Issue
Block a user