Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c5600cfc9 | |||
| c66e73e5b0 | |||
| 5f9538b5ac | |||
| 19b45cbee1 | |||
| f51f1139bf | |||
| 9c03755318 | |||
| 25a98c33b9 | |||
| 8920bead38 | |||
| 827d8aafe5 | |||
| c00a0d9eb0 | |||
| 6c3db74fd3 | |||
| ab6717eeae | |||
| c05026c5db | |||
| 9d5ae7a5bc | |||
| 4aeb1d5b66 | |||
| 57621d9346 | |||
| 0a5bd4f7e6 | |||
| a0c14db33c | |||
| b6ed45fc1b | |||
| 0299678389 | |||
| 5f18f844c1 | |||
| b48daf346b | |||
| 65f41b1ba4 | |||
| c11d70d9c9 | |||
| 783e426255 | |||
| 6fbc593916 | |||
| 262b730aca | |||
| f685d689d6 | |||
| 3c8b794efb | |||
| 0bf1cfa7f4 | |||
| 89413d6822 | |||
| adf6b517fb | |||
| f761a6bf07 | |||
| 0bc53172fc | |||
| 70c34da507 | |||
| 1cb5991c4b | |||
| 9ad729d917 | |||
| 1225e65cc3 | |||
| dedb076c94 | |||
| 3a9a4334df | |||
| a3f615a2d1 | |||
| 1413cb80fe | |||
| 1a2c4b89be | |||
| 24f1ca35d5 | |||
| ff40a7b5f3 | |||
| 6ea77725a5 | |||
| 2e3b2d7680 | |||
| 33f9f5021b | |||
| 974ca188b3 | |||
| 6e4b711ba2 | |||
| 83cbcd6c76 | |||
| 071d17d947 | |||
| 6a12608104 | |||
| c08a064e55 | |||
| 83cb632653 | |||
| 45c07789e0 | |||
| 3745970515 | |||
| 34e8c56584 | |||
| 41fdd7e066 | |||
| 7e266d5d64 | |||
| 743d5d5467 | |||
| 8e962b3af2 | |||
| ea51a8af26 | |||
| 56766c6a1a | |||
| d45f0d6bdb | |||
| bc9df316dc | |||
| 5ed2ee1192 | |||
| 061554af89 | |||
| 87cca302f4 | |||
| deb127ae08 | |||
| a9438ed996 | |||
| 72f47592a9 | |||
| b6c340a352 | |||
| 6e76b45d34 | |||
| cd8e77c1f6 | |||
| 4ae6394c95 | |||
| e052c0545e | |||
| ee6fa3e1cf | |||
| afe78fb25f | |||
| 42939a557f | |||
| 864f0e8eb2 | |||
| 243b0e7c78 | |||
| dddbd3bfa2 | |||
| ab23ca7e5a | |||
| dc816991d6 |
@@ -0,0 +1,105 @@
|
||||
name: CI Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements-dev.txt'
|
||||
- 'infra/docker/ci.Dockerfile'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "手动触发 - ci-base 镜像重建"
|
||||
|
||||
concurrency:
|
||||
group: ci-base-image-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-ci-base:
|
||||
name: Build CI Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Docker login to Gitea Registry
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Build and push CI base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
|
||||
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
|
||||
|
||||
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
|
||||
docker build --progress=plain \
|
||||
-f infra/docker/ci.Dockerfile \
|
||||
-t "${IMAGE}:latest" \
|
||||
-t "${IMAGE}:${VERSION_TAG}" \
|
||||
.
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
echo "=== Pushing ${VERSION_TAG} ==="
|
||||
docker push "${IMAGE}:${VERSION_TAG}"
|
||||
echo "=== Pushing latest ==="
|
||||
docker push "${IMAGE}:latest"
|
||||
echo "✅ Pushed to Gitea Registry"
|
||||
|
||||
- name: Verify image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
|
||||
echo "=== Verifying pinned deps in fresh image ==="
|
||||
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
|
||||
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
|
||||
|
||||
- name: Notify result
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
|
||||
else
|
||||
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
|
||||
docker rmi "${IMAGE}:latest" 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
+153
-151
@@ -81,12 +81,6 @@ jobs:
|
||||
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 files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -125,14 +119,14 @@ jobs:
|
||||
[ ${{ 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
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-code-quality:
|
||||
validate-style:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Code Quality
|
||||
name: Validate - Style
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
@@ -156,11 +150,12 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-codequality-${{ hashFiles('requirements*.txt') }}
|
||||
key: ${{ runner.os }}-pip-style-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-codequality-
|
||||
${{ runner.os }}-pip-style-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
@@ -184,17 +179,9 @@ jobs:
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 && break
|
||||
echo "pip install black/isort 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run code quality and security checks
|
||||
- name: Run style checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_code_quality.sh
|
||||
run: bash scripts/ci/validate_style.sh
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
@@ -210,7 +197,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Style" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -223,7 +210,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Style" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -236,12 +223,16 @@ 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
|
||||
|
||||
validate-type-check:
|
||||
|
||||
validate-security:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Type Check (mypy)
|
||||
name: Validate - Security
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -260,6 +251,116 @@ jobs:
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-security-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-security-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run security checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_security.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Security" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- 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="Validate - Security" 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
|
||||
|
||||
|
||||
validate-python:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Python (mypy + alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
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: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-python-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-python-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -285,6 +386,9 @@ jobs:
|
||||
- name: Run mypy type check
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_mypy.sh
|
||||
- name: Run alembic migration validation
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_migration.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
@@ -293,7 +397,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Python (mypy + alembic)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -306,7 +410,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Python (mypy + alembic)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -319,92 +423,6 @@ 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
|
||||
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
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: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run alembic migration validation
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_migration.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- 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="Validate - Migration (alembic)" 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
|
||||
|
||||
unit-tests:
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
@@ -441,6 +459,7 @@ jobs:
|
||||
run: bash scripts/ci/step_install_ffmpeg.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -495,9 +514,6 @@ jobs:
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- dedupe-check
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -652,6 +668,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
@@ -858,19 +875,13 @@ jobs:
|
||||
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
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_push_paths.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_push_paths.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_push_paths.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -881,7 +892,7 @@ jobs:
|
||||
[ ${{ 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
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
@@ -1139,9 +1150,7 @@ jobs:
|
||||
- check-push-paths
|
||||
- build-staging
|
||||
- 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')
|
||||
if: (!cancelled()) && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -1399,8 +1408,9 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-style
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
@@ -1886,9 +1896,9 @@ jobs:
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- validate-style
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- integration-tests
|
||||
- frontend-lint
|
||||
@@ -1896,14 +1906,6 @@ jobs:
|
||||
- build-pr
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
|
||||
- name: Evaluate CI Gate
|
||||
id: gate
|
||||
shell: bash
|
||||
@@ -1912,9 +1914,9 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
|
||||
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
|
||||
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
|
||||
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
|
||||
RESULT_STYLE: ${{ needs.validate-style.result }}
|
||||
RESULT_SECURITY: ${{ needs.validate-security.result }}
|
||||
RESULT_PYTHON: ${{ needs.validate-python.result }}
|
||||
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
|
||||
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
|
||||
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
|
||||
@@ -1926,9 +1928,9 @@ jobs:
|
||||
echo ""
|
||||
echo "各job结果:"
|
||||
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
|
||||
echo " validate-code-quality: $RESULT_CODE_QUALITY"
|
||||
echo " validate-type-check: $RESULT_TYPE_CHECK"
|
||||
echo " validate-migration: $RESULT_MIGRATION"
|
||||
echo " validate-style: $RESULT_STYLE"
|
||||
echo " validate-security: $RESULT_SECURITY"
|
||||
echo " validate-python: $RESULT_PYTHON"
|
||||
echo " unit-tests: $RESULT_UNIT_TESTS"
|
||||
echo " integration-tests: $RESULT_INTEGRATION"
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
@@ -1968,9 +1970,9 @@ jobs:
|
||||
# 必填检查项(根据PR类型决定)
|
||||
# 通用检查(所有PR都必须过)
|
||||
REQUIRED_GENERAL=(
|
||||
"validate-code-quality:$RESULT_CODE_QUALITY"
|
||||
"validate-type-check:$RESULT_TYPE_CHECK"
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"validate-style:$RESULT_STYLE"
|
||||
"validate-security:$RESULT_SECURITY"
|
||||
"validate-python:$RESULT_PYTHON"
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"build-pr:$RESULT_BUILD_PR"
|
||||
"ai-code-review:$AI_REVIEW_STATUS"
|
||||
@@ -2065,4 +2067,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
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -3,7 +3,7 @@ name: PR Auto Scan
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
|
||||
- cron: "*/15 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add duplicate_rate to generated_videos
|
||||
|
||||
Revision ID: 059_duplicate_rate
|
||||
Revises: 058_uq_asset_lib_project_kind
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "059_duplicate_rate"
|
||||
down_revision = "058_uq_asset_lib_project_kind"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "duplicate_rate")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""migrate template_segments data to template_clip_configs
|
||||
|
||||
Revision ID: 060_migrate_segments
|
||||
Revises: 059_duplicate_rate
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "060_migrate_segments"
|
||||
down_revision = "059_duplicate_rate"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
dialect = op.get_bind().dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN json_build_object('material_type', s.material_type)::jsonb "
|
||||
"ELSE '{}'::jsonb END"
|
||||
)
|
||||
empty_json = "'{}'::jsonb"
|
||||
else:
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN JSON_OBJECT('material_type', s.material_type) "
|
||||
"ELSE '{}' END"
|
||||
)
|
||||
empty_json = "'{}'"
|
||||
|
||||
sql_str = (
|
||||
"INSERT INTO template_clip_configs "
|
||||
'(id, template_id, clip_type, "order", min_duration, max_duration, '
|
||||
"text_template, material_requirements, transition_effect, config, "
|
||||
"created_at, updated_at) "
|
||||
"SELECT "
|
||||
"s.id, s.template_id, 'main', s.segment_order, "
|
||||
"s.duration_min, s.duration_max, "
|
||||
"'', " + empty_json + ", "
|
||||
"'cut', " + config_expr + ", "
|
||||
"s.created_at, s.updated_at "
|
||||
"FROM template_segments s "
|
||||
"WHERE NOT EXISTS ("
|
||||
" SELECT 1 FROM template_clip_configs c "
|
||||
" WHERE c.template_id = s.template_id"
|
||||
")"
|
||||
)
|
||||
op.execute(sa.text(sql_str))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -26,7 +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 app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
@@ -576,47 +576,93 @@ def smart_match_assets(
|
||||
request.library_id, request.kind, status=["ready"], limit=10000
|
||||
)
|
||||
else:
|
||||
filtered_assets = asset_repository.find_by_library(
|
||||
request.library_id, status=["ready"], limit=10000
|
||||
)
|
||||
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
|
||||
total_candidates = len(filtered_assets)
|
||||
|
||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(
|
||||
filtered_assets,
|
||||
limit=request.limit,
|
||||
kind=None,
|
||||
)
|
||||
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ──────────
|
||||
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补,
|
||||
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。
|
||||
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。
|
||||
|
||||
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
|
||||
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
|
||||
filtered_results = []
|
||||
for r in results:
|
||||
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
|
||||
usable_assets = []
|
||||
exhausted_assets = []
|
||||
for a in filtered_assets:
|
||||
try:
|
||||
avail = compute_asset_availability(r.asset)
|
||||
avail = compute_asset_availability(a)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"smart-match 余量计算失败,按可用处理: asset_id=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(a, "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)
|
||||
exhausted_assets.append(a)
|
||||
else:
|
||||
usable_assets.append(a)
|
||||
|
||||
if exhausted_assets:
|
||||
logger.info(
|
||||
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
|
||||
len(filtered_assets), len(exhausted_assets),
|
||||
)
|
||||
|
||||
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
|
||||
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
|
||||
# (全选同样会选到这些素材,且不经过评分排序)。
|
||||
pool = usable_assets if usable_assets else filtered_assets
|
||||
|
||||
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
MAX_RECENT_USE_COUNT = 3
|
||||
high_freq_assets = set()
|
||||
if pool:
|
||||
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
|
||||
if asset_ids:
|
||||
try:
|
||||
use_counts = get_asset_recent_use_counts(
|
||||
db=asset_repository.session,
|
||||
asset_ids=asset_ids,
|
||||
recent_video_count=5,
|
||||
)
|
||||
for a in pool:
|
||||
aid = getattr(a, "id", "")
|
||||
count = use_counts.get(aid, 0)
|
||||
if count > MAX_RECENT_USE_COUNT:
|
||||
high_freq_assets.add(aid)
|
||||
logger.info(
|
||||
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
|
||||
aid, count, MAX_RECENT_USE_COUNT,
|
||||
)
|
||||
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
|
||||
# 不再全部排除,保留全部可用素材
|
||||
if high_freq_assets:
|
||||
remaining_count = len(pool) - len(high_freq_assets)
|
||||
enough = request.limit is None or remaining_count >= request.limit
|
||||
if remaining_count > 0 and enough:
|
||||
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
|
||||
else:
|
||||
logger.info(
|
||||
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d 条",
|
||||
remaining_count,
|
||||
request.limit if request.limit is not None else "不限",
|
||||
len(pool),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
|
||||
|
||||
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(pool, limit=request.limit, kind=None)
|
||||
|
||||
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
|
||||
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
|
||||
items = [
|
||||
SmartMatchItem(
|
||||
asset=_to_asset_response(r.asset),
|
||||
**_to_asset_response(r.asset).model_dump(),
|
||||
score=r.score,
|
||||
breakdown=r.breakdown,
|
||||
)
|
||||
for r in filtered_results
|
||||
for r in results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
@@ -23,6 +23,10 @@ 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
|
||||
|
||||
# 默认转场时长(与 worker 端保持一致)
|
||||
_DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
from app.services.asset_segment_tracker import (
|
||||
REUSE_RATIO_LIMIT,
|
||||
SEGMENT_EDGE_GAP,
|
||||
@@ -37,10 +41,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
@@ -367,7 +375,6 @@ def batch_delete_editor_clips(
|
||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
||||
|
||||
|
||||
|
||||
def _safe_segment_duration(value, default: float) -> float:
|
||||
"""安全地将数据库中的时长值转换为正浮点数.
|
||||
|
||||
@@ -403,9 +410,7 @@ def _get_template_segments(
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(
|
||||
cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION
|
||||
)
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
@@ -414,7 +419,25 @@ def _get_template_segments(
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
|
||||
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
|
||||
|
||||
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
|
||||
try:
|
||||
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
direct_configs = direct_repo.list_by_template(template_id)
|
||||
if direct_configs:
|
||||
result = []
|
||||
for cc in direct_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
@@ -492,7 +515,7 @@ def _get_mediakit_recommendations(
|
||||
prompt = (
|
||||
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
|
||||
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
|
||||
'请严格以JSON数组格式返回,不要包含其他文字:'
|
||||
"请严格以JSON数组格式返回,不要包含其他文字:"
|
||||
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
|
||||
)
|
||||
|
||||
@@ -545,9 +568,7 @@ def _get_mediakit_recommendations(
|
||||
|
||||
# 尝试正则提取
|
||||
if not parsed:
|
||||
time_match = re.search(
|
||||
r'recommended_start_time["\s:]+([\d.]+)', content_text
|
||||
)
|
||||
time_match = re.search(r'recommended_start_time["\s:]+([\d.]+)', content_text)
|
||||
if time_match:
|
||||
try:
|
||||
recommendations[asset_id] = float(time_match.group(1))
|
||||
@@ -566,6 +587,47 @@ def _get_mediakit_recommendations(
|
||||
return {}
|
||||
|
||||
|
||||
def _calc_plan_internal_duplicate_rate(clips_data: list[dict]) -> float:
|
||||
"""估算单条成片内部重复率(%).
|
||||
|
||||
检查本条成片中同一素材是否有重叠的时间区间。
|
||||
重叠时长 / 成片总时长 * 100 = 内部重复率。
|
||||
这是一个轻量估算,不依赖视频指纹;完整查重由 worker 异步完成。
|
||||
"""
|
||||
if not clips_data:
|
||||
return 0.0
|
||||
|
||||
# 按素材分组
|
||||
by_asset: dict[str, list[tuple[float, float]]] = {}
|
||||
total_duration = 0.0
|
||||
for c in clips_data:
|
||||
aid = c.get("asset_id", "")
|
||||
if not aid:
|
||||
continue
|
||||
start = c.get("start_time", 0.0)
|
||||
end = start + c.get("duration", 0.0)
|
||||
by_asset.setdefault(aid, []).append((start, end))
|
||||
total_duration += c.get("duration", 0.0)
|
||||
|
||||
if total_duration <= 0:
|
||||
return 0.0
|
||||
|
||||
# 检查同素材内的区间重叠
|
||||
overlap_duration = 0.0
|
||||
for segments in by_asset.values():
|
||||
if len(segments) < 2:
|
||||
continue
|
||||
segments_sorted = sorted(segments, key=lambda s: s[0])
|
||||
last_end = segments_sorted[0][1]
|
||||
for start, end in segments_sorted[1:]:
|
||||
overlap = max(0.0, min(end, last_end) - start)
|
||||
if overlap > 0:
|
||||
overlap_duration += overlap
|
||||
last_end = max(last_end, end)
|
||||
|
||||
return round(overlap_duration / total_duration * 100, 1)
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets_editor(
|
||||
template_id: str,
|
||||
@@ -598,26 +660,31 @@ def create_clips_from_assets_editor(
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
)
|
||||
|
||||
if not body.asset_ids:
|
||||
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
|
||||
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
|
||||
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
|
||||
if not asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材列表为空,无法创建片段",
|
||||
)
|
||||
|
||||
# 2. 获取素材实际时长(去重查询)
|
||||
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
|
||||
unique_asset_ids = list(dict.fromkeys(asset_ids))
|
||||
asset_durations: dict[str, float] = {}
|
||||
asset_smart_scores: dict[str, float] = {}
|
||||
for asset_id in unique_asset_ids:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if asset and hasattr(asset, "duration"):
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
smart_score, _ = score_asset(asset)
|
||||
asset_smart_scores[asset_id] = smart_score
|
||||
|
||||
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
|
||||
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
|
||||
# 格式与 _calc_random_start_time 的 used_segments 参数一致
|
||||
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(
|
||||
db, unique_asset_ids
|
||||
)
|
||||
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(db, unique_asset_ids)
|
||||
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
|
||||
# 复用片段时长累加到 reused_durations 供 15% 占比控制
|
||||
reused_durations: dict[str, float] = {}
|
||||
@@ -645,19 +712,52 @@ def create_clips_from_assets_editor(
|
||||
return False
|
||||
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 素材耗尽标志:某轮循环中所有素材均被跳过时为 True
|
||||
all_assets_exhausted = False
|
||||
|
||||
# 计算转场重叠补偿:每个 clip 需要额外增加的时长
|
||||
# 目标:渲染后视频总时长 = 模板设定的各片段时长之和
|
||||
# 公式:每 clip 增加 (n_segments - 1) * td / n_segments
|
||||
n_segments = len(segments)
|
||||
if n_segments > 1:
|
||||
transition_compensation = (n_segments - 1) * _DEFAULT_TRANSITION_DURATION / n_segments
|
||||
else:
|
||||
transition_compensation = 0.0
|
||||
|
||||
# 打乱 segments 的处理顺序(分配素材的顺序随机化),但最终 clips_data 按原始 order 排序
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
|
||||
for idx in shuffled_indices:
|
||||
_seg_order, dur_min, dur_max = segments[idx]
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
|
||||
raw_duration += transition_compensation
|
||||
|
||||
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
|
||||
# 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先),
|
||||
# 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。
|
||||
# 跳过时长缺失、复用占比已超 10% 阈值的素材;
|
||||
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(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]
|
||||
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
|
||||
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in asset_ids}
|
||||
# 排序键:smart_match 评分(注入随机噪声)→ 使用次数 → 纯随机。
|
||||
# 噪声让得分接近的素材排名每次浮动,避免同一批素材反复选出相同组合,
|
||||
# 从素材组合层面降低成片查重率;分差 > SCORE_RANDOM_NOISE_MAX 时排名稳定,
|
||||
# 质量差距显著的素材仍保持优先级。
|
||||
sorted_candidates = sorted(
|
||||
asset_ids,
|
||||
key=lambda aid: (
|
||||
-(asset_smart_scores.get(aid, 0.0) + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX)),
|
||||
asset_use_counts.get(aid, 0),
|
||||
random.random(),
|
||||
),
|
||||
)
|
||||
for candidate in sorted_candidates:
|
||||
candidate_total = asset_durations.get(candidate, 0.0)
|
||||
if candidate_total <= 0:
|
||||
continue
|
||||
@@ -673,7 +773,7 @@ def create_clips_from_assets_editor(
|
||||
continue
|
||||
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
|
||||
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
|
||||
# 预判复用后占比超 15% 则拒绝并返回 None)
|
||||
# 预判复用后占比超 10% 则拒绝并返回 None)
|
||||
candidate_start = _calc_random_start_time(
|
||||
candidate,
|
||||
candidate_duration,
|
||||
@@ -695,28 +795,23 @@ def create_clips_from_assets_editor(
|
||||
|
||||
if not asset_id or start_time is None:
|
||||
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
|
||||
all_assets_exhausted = True
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材可切区间不足,请补充新素材",
|
||||
)
|
||||
|
||||
# 记录已使用时间段(内存,供本次后续片段避开)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
record_used_segments(db, asset_id, start_time, start_time + clip_duration, plan_id)
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": i,
|
||||
"order": _seg_order,
|
||||
"asset_id": asset_id,
|
||||
"start_time": start_time,
|
||||
"duration": clip_duration,
|
||||
@@ -724,6 +819,9 @@ def create_clips_from_assets_editor(
|
||||
}
|
||||
)
|
||||
|
||||
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...)
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
|
||||
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
@@ -743,19 +841,86 @@ def create_clips_from_assets_editor(
|
||||
unique_asset_ids,
|
||||
)
|
||||
|
||||
# 6. 立即返回响应
|
||||
# 6. 估算成片内部重复率(本条成片中同一素材的重叠片段时长占比)
|
||||
dup_rate = _calc_plan_internal_duplicate_rate(clips_data)
|
||||
duplicate_warning = None
|
||||
if dup_rate > 50:
|
||||
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
|
||||
logger.warning(
|
||||
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
|
||||
plan_id,
|
||||
dup_rate,
|
||||
)
|
||||
|
||||
# 7. 素材耗尽提示
|
||||
exhaustion_warning = None
|
||||
if all_assets_exhausted and created_count < len(segments):
|
||||
exhaustion_warning = (
|
||||
"素材可切区间不足,部分片段使用了复用素材。" "建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
|
||||
)
|
||||
|
||||
# 8. 立即返回响应
|
||||
return ClipsFromAssetsResponse(
|
||||
created_count=created_count,
|
||||
plan_id=plan_id,
|
||||
clip_ids=[],
|
||||
duplicate_warning=duplicate_warning,
|
||||
exhaustion_warning=exhaustion_warning,
|
||||
)
|
||||
|
||||
|
||||
def _build_scene_segments(
|
||||
scene_changes: list[float],
|
||||
asset_duration: float,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""根据场景切换点构建镜头段列表.
|
||||
|
||||
Args:
|
||||
scene_changes: 场景切换点时间戳列表(已排序,首位为 0.0)
|
||||
asset_duration: 素材总时长
|
||||
|
||||
Returns:
|
||||
镜头段列表 [(start, end), ...]
|
||||
"""
|
||||
segments: list[tuple[float, float]] = []
|
||||
for i, ts in enumerate(scene_changes):
|
||||
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
|
||||
# 只保留有效长度的镜头段(至少 0.5 秒)
|
||||
if end - ts >= 0.5:
|
||||
segments.append((ts, end))
|
||||
return segments
|
||||
|
||||
|
||||
def _pick_start_in_scene_segment(
|
||||
seg_start: float,
|
||||
seg_end: float,
|
||||
clip_duration: float,
|
||||
) -> float | None:
|
||||
"""在镜头段内随机选取一个起始时间点.
|
||||
|
||||
确保 start + clip_duration <= seg_end。
|
||||
若镜头段长度不足以容纳片段,返回 None。
|
||||
"""
|
||||
available = seg_end - seg_start - clip_duration
|
||||
if available < 0:
|
||||
return None
|
||||
max_start = seg_start + available
|
||||
return random.uniform(seg_start, max_start)
|
||||
|
||||
|
||||
def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
) -> None:
|
||||
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
|
||||
"""后台任务:使用 SceneChange 智能选帧并更新片段的起始时间.
|
||||
|
||||
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
|
||||
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
|
||||
|
||||
降级策略:
|
||||
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
|
||||
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
|
||||
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
|
||||
|
||||
此函数在后台异步执行,不影响接口响应时间。
|
||||
失败时静默处理,不影响已创建的片段。
|
||||
@@ -777,12 +942,6 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
plan_svc = EditPlanService(db)
|
||||
|
||||
# 调用 MediaKit 获取推荐时间
|
||||
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
|
||||
if not recommendations:
|
||||
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
|
||||
return
|
||||
|
||||
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
|
||||
batch_size = 500
|
||||
all_clips = []
|
||||
@@ -803,21 +962,18 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
|
||||
# 批量预加载所有涉及的素材(消除 N+1 查询)
|
||||
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
|
||||
assets_map: dict[str, object] = {
|
||||
a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)
|
||||
}
|
||||
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
|
||||
|
||||
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
|
||||
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
|
||||
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段)
|
||||
clips_by_asset: dict[str, list] = defaultdict(list)
|
||||
for clip in clips:
|
||||
aid = getattr(clip, "asset_id", "") or ""
|
||||
if aid and clip.start_time is not None:
|
||||
clips_by_asset[aid].append(
|
||||
(clip.id, clip.start_time, clip.start_time + clip.duration)
|
||||
)
|
||||
if aid:
|
||||
clips_by_asset[aid].append(clip)
|
||||
for aid in clips_by_asset:
|
||||
clips_by_asset[aid].sort(key=lambda c: c.order)
|
||||
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
|
||||
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
|
||||
historical_segments = get_used_segments(db, unique_asset_ids)
|
||||
|
||||
# 已更新的片段ID(用于排除已移动的旧时间段)
|
||||
@@ -826,16 +982,22 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
updated_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
updated_count = 0
|
||||
|
||||
# 遍历片段,按 asset_id 匹配推荐时间
|
||||
for clip in clips:
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
if not asset_id or asset_id not in recommendations:
|
||||
# 尝试获取存储服务(用于生成视频 URL)
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception:
|
||||
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
|
||||
return
|
||||
|
||||
# 获取 MediaKit 客户端
|
||||
client = get_mediakit_client()
|
||||
|
||||
# 对每个素材,检测场景切换点并分配镜头段
|
||||
for asset_id in unique_asset_ids:
|
||||
asset_clips = clips_by_asset.get(asset_id, [])
|
||||
if not asset_clips:
|
||||
continue
|
||||
|
||||
recommended_start = recommendations[asset_id]
|
||||
clip_duration = clip.duration
|
||||
|
||||
# 从预加载字典获取素材(O(1) 查找)
|
||||
asset = assets_map.get(asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
@@ -843,96 +1005,147 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if asset_total <= 0:
|
||||
continue
|
||||
|
||||
# 推荐时间 + 片段时长不能超过素材总时长
|
||||
if recommended_start + clip_duration > asset_total:
|
||||
logger.info(
|
||||
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
clip_duration,
|
||||
asset_total,
|
||||
)
|
||||
continue
|
||||
|
||||
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
|
||||
other_segments: list[tuple[float, float]] = [
|
||||
(cs, ce)
|
||||
for cid, cs, ce in clips_by_asset.get(asset_id, [])
|
||||
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,
|
||||
recommended_start,
|
||||
)
|
||||
continue
|
||||
|
||||
# 逐个更新并捕获异常(单点失败不影响其他片段)
|
||||
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)
|
||||
# 获取素材视频 URL
|
||||
video_url: str | None = None
|
||||
storage_key = getattr(asset, "storage_key", None) or ""
|
||||
mime = getattr(asset, "mime_type", "") or ""
|
||||
if storage_key and mime.startswith("video/"):
|
||||
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,
|
||||
video_url = storage.get_download_url(storage_key)
|
||||
except Exception as e:
|
||||
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
# 构建该素材的占用区间列表(排除已更新片段)
|
||||
def _get_other_segments(asset_id_inner, clip_id_inner):
|
||||
segs: list[tuple[float, float]] = []
|
||||
for c in clips_by_asset.get(asset_id_inner, []):
|
||||
cid = c.id
|
||||
if cid != clip_id_inner and cid not in updated_clip_ids:
|
||||
segs.append((c.start_time, c.start_time + c.duration))
|
||||
segs.extend(updated_segments.get(asset_id_inner, []))
|
||||
|
||||
# 并入历史已用区间
|
||||
def _norm(segs_in):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
|
||||
|
||||
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
|
||||
|
||||
# 优先使用 SceneChange 策略
|
||||
scene_segments: list[tuple[float, float]] = []
|
||||
if client.is_available and video_url:
|
||||
scene_changes = client.detect_scene_changes(video_url)
|
||||
if scene_changes is not None:
|
||||
scene_segments = _build_scene_segments(scene_changes, asset_total)
|
||||
logger.info(
|
||||
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
|
||||
asset_id,
|
||||
len(scene_segments),
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning(
|
||||
"后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
if not scene_segments and video_url:
|
||||
fallback_recs = _get_mediakit_recommendations([asset_id], asset_repo)
|
||||
if fallback_recs and asset_id in fallback_recs:
|
||||
# analyze_videos 只返回单个推荐点,转为单镜头段
|
||||
rec_start = fallback_recs[asset_id]
|
||||
scene_segments = [(rec_start, asset_total)]
|
||||
logger.info(
|
||||
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
|
||||
asset_id,
|
||||
rec_start,
|
||||
)
|
||||
|
||||
if not scene_segments:
|
||||
# 所有方式都失败 → 保持现有随机 start_time
|
||||
logger.info(
|
||||
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
|
||||
asset_id,
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
# 为每个片段分配不同的镜头段
|
||||
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
|
||||
for clip in asset_clips:
|
||||
clip_duration = clip.duration
|
||||
recommended_start: float | None = None
|
||||
|
||||
# 从镜头段池中依次尝试,选一个不冲突的
|
||||
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool):
|
||||
candidate_start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
if candidate_start is None:
|
||||
continue # 镜头段太短,跳过
|
||||
|
||||
# 检查越界
|
||||
if candidate_start + clip_duration > asset_total:
|
||||
continue
|
||||
|
||||
# 检查与已用区间冲突
|
||||
other_segs = _get_other_segments(asset_id, clip.id)
|
||||
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
|
||||
continue
|
||||
|
||||
recommended_start = candidate_start
|
||||
# 消费该镜头段(从池中移除,下一个片段用不同镜头段)
|
||||
scene_segments_pool.pop(seg_idx)
|
||||
break
|
||||
|
||||
if recommended_start is None:
|
||||
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底
|
||||
used_segs_for_calc: dict[str, list[tuple[float, float]]] = {
|
||||
asset_id: _get_other_segments(asset_id, clip.id)
|
||||
}
|
||||
fallback_start = _calc_random_start_time(
|
||||
asset_id,
|
||||
clip_duration,
|
||||
{asset_id: asset_total},
|
||||
used_segs_for_calc,
|
||||
)
|
||||
if fallback_start is None:
|
||||
continue # 完全无法分配,保持原起点
|
||||
recommended_start = fallback_start
|
||||
|
||||
# 更新片段起始时间
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
|
||||
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)
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
@@ -950,4 +1163,3 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -167,7 +167,18 @@ class ClipsFromAssetsRequest(BaseModel):
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量")
|
||||
required_clips_count: Optional[int] = Field(
|
||||
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
|
||||
)
|
||||
|
||||
@validator("asset_ids", pre=True)
|
||||
def _drop_invalid_asset_ids(cls, v): # noqa: N805
|
||||
"""容错过滤:前端异常情况下可能把 undefined 序列化成 null 或空串混入
|
||||
asset_ids(会直接 422 或导致后续 /assets/{id} 404),这里统一剔除。
|
||||
过滤后为空时由 Field(min_length=1) / 路由层 400 兜底。"""
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
return [x for x in v if isinstance(x, str) and x.strip()]
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
@@ -178,6 +189,8 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
plan_id: str = ""
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
|
||||
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
|
||||
|
||||
|
||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,6 +52,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
|
||||
duplicate_rate=getattr(item, "duplicate_rate", None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -129,10 +129,13 @@ class SmartMatchRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SmartMatchItem(BaseModel):
|
||||
"""智能选素材结果条目。"""
|
||||
class SmartMatchItem(AssetResponse):
|
||||
"""智能选素材结果条目(扁平结构)。
|
||||
|
||||
素材字段(id/usable/余量等)直接挂在条目顶层,前端拿到 item 即可读 item.id,
|
||||
与 AssetResponse 字段完全一致;score/breakdown 为智能匹配附加的评分字段。
|
||||
"""
|
||||
|
||||
asset: AssetResponse
|
||||
score: float = Field(..., ge=0, le=100, description="综合得分 0-100")
|
||||
breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细")
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class VideoItemResponse(BaseModel):
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
generated_at: str = ""
|
||||
duplicate_rate: float | None = None
|
||||
|
||||
|
||||
class ListVideosResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
在素材 metadata(assets.classification_result JSON)中持久化已使用的片段时间区间,
|
||||
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
|
||||
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
|
||||
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
|
||||
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 10%),把任意两条
|
||||
成片的画面重复率控制在阈值内。
|
||||
|
||||
metadata 中的记录字段 ``used_time_ranges``::
|
||||
@@ -40,14 +40,14 @@ logger = logging.getLogger(__name__)
|
||||
USED_RANGES_KEY = "used_time_ranges"
|
||||
|
||||
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
|
||||
MAX_RANGE_USE_COUNT = 3
|
||||
MAX_RANGE_USE_COUNT = 2
|
||||
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
|
||||
|
||||
REUSE_RATIO_LIMIT = 0.15
|
||||
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
|
||||
REUSE_RATIO_LIMIT = 0.10
|
||||
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(10%)。
|
||||
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
|
||||
|
||||
SEGMENT_EDGE_GAP = 0.3
|
||||
SEGMENT_EDGE_GAP = 1.5
|
||||
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
|
||||
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
|
||||
|
||||
@@ -397,12 +397,12 @@ def make_reuse_callback(
|
||||
db: SQLAlchemy session
|
||||
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
|
||||
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
|
||||
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
|
||||
会把本次片段时长累加进去,供调用方统计成片复用占比(10% 阈值)。
|
||||
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
|
||||
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
|
||||
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
|
||||
(保证成片复用占比不超阈值)。
|
||||
ratio_limit: 单条成片复用时长占比上限,默认 15%。
|
||||
ratio_limit: 单条成片复用时长占比上限,默认 10%。
|
||||
|
||||
Returns:
|
||||
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
|
||||
@@ -438,3 +438,57 @@ def make_reuse_callback(
|
||||
return result
|
||||
|
||||
return _reuse
|
||||
|
||||
|
||||
def get_asset_recent_use_counts(
|
||||
db: Session,
|
||||
asset_ids: list[str],
|
||||
recent_video_count: int = 5,
|
||||
) -> dict[str, int]:
|
||||
"""统计每个素材在最近 N 个不同 plan_id 中的使用次数。
|
||||
|
||||
遍历素材 metadata 中的 used_time_ranges,统计有多少个不同的 plan_id(去重),
|
||||
返回 {asset_id: count}。只统计最近 recent_video_count 个不同 plan_id 的使用次数。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
asset_ids: 素材 ID 列表
|
||||
recent_video_count: 统计最近多少个不同 plan_id
|
||||
|
||||
Returns:
|
||||
{asset_id: 在最近 recent_video_count 个 plan 中的使用次数}
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
result: dict[str, int] = {}
|
||||
models = db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
for model in models:
|
||||
meta = _read_meta(model)
|
||||
ranges = meta.get(USED_RANGES_KEY) or []
|
||||
if not ranges:
|
||||
result[model.id] = 0
|
||||
continue
|
||||
|
||||
# 按 created_at 倒序收集不同 plan_id
|
||||
sorted_ranges = sorted(
|
||||
ranges,
|
||||
key=lambda r: r.get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
recent_plan_ids: set[str] = set()
|
||||
for r in sorted_ranges:
|
||||
plan_id = r.get("plan_id")
|
||||
if plan_id:
|
||||
recent_plan_ids.add(plan_id)
|
||||
if len(recent_plan_ids) >= recent_video_count:
|
||||
break
|
||||
|
||||
result[model.id] = len(recent_plan_ids)
|
||||
|
||||
# 未找到的素材计为 0
|
||||
for aid in asset_ids:
|
||||
if aid not in result:
|
||||
result[aid] = 0
|
||||
|
||||
return result
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -32,6 +33,7 @@ from packages.domain.plan_generator_utils import (
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -218,8 +220,13 @@ class PlanGeneratorService:
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
先用 smart_match 评分对素材排序(高分优先),再委托给
|
||||
plan_generator_utils.distribute_assets 纯函数完成分配。
|
||||
"""
|
||||
# 用 smart_match 评分排序素材:高分(质量好/时长合适/新鲜/未使用)优先
|
||||
if self._asset_repo and not random_selection:
|
||||
asset_ids = self._sort_assets_by_smart_score(asset_ids)
|
||||
|
||||
distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
@@ -228,6 +235,29 @@ class PlanGeneratorService:
|
||||
asset_durations=asset_durations,
|
||||
)
|
||||
|
||||
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
|
||||
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
|
||||
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
|
||||
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
|
||||
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
|
||||
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
|
||||
"""
|
||||
scored: list[tuple[str, float]] = []
|
||||
for asset_id in asset_ids:
|
||||
asset = self._asset_repo.get(asset_id)
|
||||
if asset:
|
||||
score, _ = score_asset(asset)
|
||||
scored.append((asset_id, score))
|
||||
else:
|
||||
scored.append((asset_id, 0.0))
|
||||
# 评分 + 随机噪声后按降序排列
|
||||
scored.sort(
|
||||
key=lambda x: x[1] + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX),
|
||||
reverse=True,
|
||||
)
|
||||
return [aid for aid, _ in scored]
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
"""从数据库获取素材时长信息.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Generated
+7
-14
@@ -1848,10 +1848,9 @@
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
@@ -1938,10 +1937,9 @@
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
@@ -3113,10 +3111,9 @@
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
@@ -4457,10 +4454,9 @@
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
@@ -5008,10 +5004,9 @@
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
@@ -5024,10 +5019,9 @@
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -5735,10 +5729,9 @@
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
|
||||
@@ -69,10 +69,13 @@ interface SmartMatchWrappedItem {
|
||||
breakdown?: unknown
|
||||
}
|
||||
|
||||
export const smartMatchAssets = async (libraryId: string): Promise<SmartMatchResult> => {
|
||||
const response = await apiClient.post("/assets/smart-match", {
|
||||
library_id: libraryId,
|
||||
})
|
||||
export const smartMatchAssets = async (
|
||||
libraryId: string,
|
||||
limit?: number,
|
||||
): Promise<SmartMatchResult> => {
|
||||
const payload: Record<string, unknown> = { library_id: libraryId }
|
||||
if (limit && limit > 0) payload.limit = limit
|
||||
const response = await apiClient.post("/assets/smart-match", payload)
|
||||
const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? []
|
||||
const items = rawItems
|
||||
.map((it) =>
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
* 素材诊断 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetDiagnosis } from "./types"
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
export const getAssetDiagnosis = async (
|
||||
assetId?: string,
|
||||
projectId?: string,
|
||||
): Promise<AssetDiagnosis> => {
|
||||
const pid = projectId ?? (await getOrCreateDefaultProject()).id
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
const response = await apiClient.get(`/projects/${pid}/asset-diagnosis`, { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -71,4 +71,5 @@ export interface VideoItem {
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
duplicate_rate?: number
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
duplicate_rate: video.duplicate_rate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
segment_order: i,
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
duration_min: c.duration,
|
||||
duration_max: c.duration,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
|
||||
@@ -338,12 +338,15 @@ const GeneratePage: React.FC = () => {
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom",
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 5 && generated && finalVideo && (
|
||||
|
||||
@@ -32,12 +32,15 @@ interface FrontendPreviewPlayerProps {
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
posX?: number | null
|
||||
posY?: number | null
|
||||
}
|
||||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -105,6 +108,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips),
|
||||
@@ -126,6 +130,69 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
})()
|
||||
const customTitleXPct =
|
||||
titleSettings?.posX != null && playRes.width > 0
|
||||
? (titleSettings.posX / playRes.width) * 100
|
||||
: null
|
||||
const customTitleYPct =
|
||||
titleSettings?.posY != null && playRes.height > 0
|
||||
? (titleSettings.posY / playRes.height) * 100
|
||||
: null
|
||||
|
||||
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
|
||||
const draggingTitleRef = useRef(false)
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const handleTitlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||||
draggingTitleRef.current = true
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||||
},
|
||||
[onTitlePositionChange],
|
||||
)
|
||||
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
|
||||
if (titleDragRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const xpct = (relX / rect.width) * 100
|
||||
const ypct = (relY / rect.height) * 100
|
||||
titleDragRef.current.style.left = `${xpct}%`
|
||||
titleDragRef.current.style.top = `${ypct}%`
|
||||
}
|
||||
}, [])
|
||||
const handleTitlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
|
||||
if (onTitlePositionChange && playerContainerRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const posX = Math.round((relX / rect.width) * playRes.width)
|
||||
const posY = Math.round((relY / rect.height) * playRes.height)
|
||||
onTitlePositionChange(posX, posY)
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
try {
|
||||
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
|
||||
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[onTitlePositionChange, playRes.width, playRes.height],
|
||||
)
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -524,15 +591,36 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center",
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
...(customTitleXPct != null && customTitleYPct != null
|
||||
? {
|
||||
left: `${customTitleXPct}%`,
|
||||
top: `${customTitleYPct}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
textAlign: "center" as const,
|
||||
}
|
||||
: {
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center" as const,
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: "auto",
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
padding: "8px 12px",
|
||||
boxShadow: "inset 0 0 0 16px transparent",
|
||||
}}
|
||||
ref={titleDragRef}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -23,7 +23,7 @@ const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange
|
||||
onClick={() => onModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
AI智能匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* 标题预设样式网格
|
||||
* 双图层渲染:底层=描边轮廓(text-shadow模拟),上层=填充色
|
||||
* 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题
|
||||
*/
|
||||
import React from "react"
|
||||
import { getFontFamily } from "../../constants"
|
||||
@@ -7,7 +9,10 @@ import { getFontFamily } from "../../constants"
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
previewStyle: React.CSSProperties & {
|
||||
_strokeColor?: string
|
||||
_strokeWidth?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface TitlePresetsGridProps {
|
||||
@@ -17,6 +22,32 @@ interface TitlePresetsGridProps {
|
||||
fontFamily?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 text-shadow 模拟描边轮廓(8方向 + 4对角 = 12层阴影)
|
||||
*/
|
||||
function buildStrokeShadow(color: string, width: number): string {
|
||||
const w = width
|
||||
const parts: string[] = []
|
||||
// 4 cardinal directions
|
||||
parts.push(`${w}px 0 ${color}`)
|
||||
parts.push(`${-w}px 0 ${color}`)
|
||||
parts.push(`0 ${w}px ${color}`)
|
||||
parts.push(`0 ${-w}px ${color}`)
|
||||
// 4 diagonal directions
|
||||
const d = Math.round(w * 0.71 * 10) / 10 // 0.71 ≈ sqrt(2)/2
|
||||
parts.push(`${d}px ${d}px ${color}`)
|
||||
parts.push(`${-d}px ${d}px ${color}`)
|
||||
parts.push(`${d}px ${-d}px ${color}`)
|
||||
parts.push(`${-d}px ${-d}px ${color}`)
|
||||
// 4 extra mid-points for smoother stroke
|
||||
const h = Math.round(w * 0.5 * 10) / 10
|
||||
parts.push(`${w}px ${h}px ${color}`)
|
||||
parts.push(`${w}px ${-h}px ${color}`)
|
||||
parts.push(`${-w}px ${h}px ${color}`)
|
||||
parts.push(`${-w}px ${-h}px ${color}`)
|
||||
return parts.join(", ")
|
||||
}
|
||||
|
||||
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
presets,
|
||||
activePreset,
|
||||
@@ -27,20 +58,53 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
<div className="xx-title-presets-grid">
|
||||
{presets.map((p) => {
|
||||
const isActive = activePreset === p.key
|
||||
const { _strokeColor, _strokeWidth, ...fillStyle } = p.previewStyle
|
||||
const ff = getFontFamily(fontFamily || "思源黑体")
|
||||
|
||||
// 底层:描边轮廓(用 text-shadow 模拟粗描边)
|
||||
const strokeStyle: React.CSSProperties = {
|
||||
color: _strokeColor || "transparent",
|
||||
textShadow:
|
||||
_strokeColor && _strokeWidth
|
||||
? buildStrokeShadow(_strokeColor, _strokeWidth)
|
||||
: undefined,
|
||||
fontWeight: fillStyle.fontWeight,
|
||||
fontSize: fillStyle.fontSize,
|
||||
lineHeight: 1,
|
||||
}
|
||||
|
||||
// 上层:仅填充色 + 可选 textShadow(发光/投影效果)
|
||||
const topStyle: React.CSSProperties = {
|
||||
color: fillStyle.color,
|
||||
textShadow: fillStyle.textShadow,
|
||||
fontWeight: fillStyle.fontWeight,
|
||||
fontSize: fillStyle.fontSize,
|
||||
lineHeight: 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => onApply(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
|
||||
>
|
||||
标题
|
||||
<span className="xx-title-preset-preview-text" style={{ position: "relative" }}>
|
||||
{/* 底层:描边轮廓 */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
...strokeStyle,
|
||||
fontFamily: ff,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
T
|
||||
</span>
|
||||
{/* 上层:填充色 */}
|
||||
<span style={{ ...topStyle, fontFamily: ff, position: "relative" }}>T</span>
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
@@ -77,10 +78,11 @@ export const TITLE_PRESETS = [
|
||||
label: "经典白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -88,45 +90,54 @@ export const TITLE_PRESETS = [
|
||||
label: "黑金质感",
|
||||
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
||||
previewStyle: {
|
||||
color: "#e8e8e8",
|
||||
fontWeight: 400,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
_strokeColor: "#ffffff",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 900,
|
||||
fontSize: "32px",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
||||
previewStyle: {
|
||||
color: "#e0e0e0",
|
||||
fontWeight: 400,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
fontWeight: 400,
|
||||
fontSize: "32px",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -134,10 +145,10 @@ export const TITLE_PRESETS = [
|
||||
label: "霓虹发光",
|
||||
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -145,10 +156,202 @@ export const TITLE_PRESETS = [
|
||||
label: "手写字",
|
||||
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#e0e0e0",
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
fontSize: "32px",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "outline_yellow",
|
||||
label: "黄色描边",
|
||||
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#ffd54f",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "outline_pink",
|
||||
label: "粉色描边",
|
||||
style: { size: 28, color: "#ff80ab", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#ff80ab",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "outline_blue",
|
||||
label: "蓝色描边",
|
||||
style: { size: 28, color: "#82b1ff", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#82b1ff",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "outline_green",
|
||||
label: "绿色描边",
|
||||
style: { size: 28, color: "#69f0ae", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#69f0ae",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "outline_gray",
|
||||
label: "灰色描边",
|
||||
style: { size: 28, color: "#bdbdbd", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#bdbdbd",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bg_white",
|
||||
label: "白底黑字",
|
||||
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#1a1a1a",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
background: "#ffffff",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bg_yellow",
|
||||
label: "黄底黑字",
|
||||
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#1a1a1a",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
background: "#ffd54f",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bg_pink",
|
||||
label: "粉底黑字",
|
||||
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#1a1a1a",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
background: "#ff80ab",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bg_red",
|
||||
label: "红底白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#ffffff",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
background: "#ef5350",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_orange",
|
||||
label: "橙色发光",
|
||||
style: { size: 32, color: "#ff9100", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#ff9100",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 4px #ff9100, 0 0 8px #ff9100, 0 0 16px rgba(255,145,0,0.5)",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_purple",
|
||||
label: "紫色发光",
|
||||
style: { size: 32, color: "#d500f9", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#d500f9",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 4px #d500f9, 0 0 8px #d500f9, 0 0 16px rgba(213,0,249,0.5)",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bordered_white",
|
||||
label: "白字绿框",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: {
|
||||
color: "#ffffff",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
background: "#1a1a1a",
|
||||
border: "2px solid #69f0ae",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 6px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gradient_warm",
|
||||
label: "暖色渐变",
|
||||
style: { size: 32, color: "#ff6d00", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#ff6d00",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 6px rgba(255,109,0,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gradient_cool",
|
||||
label: "冷色渐变",
|
||||
style: { size: 32, color: "#00b0ff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#00b0ff",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 6px rgba(0,176,255,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "shadow_deep",
|
||||
label: "深影白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#ffffff",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.8), 0 0 8px rgba(0,0,0,0.4)",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "soft_gold",
|
||||
label: "柔光金",
|
||||
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#ffd54f",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 6px rgba(255,213,79,0.5), 1px 1px 2px rgba(0,0,0,0.4)",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1733,50 +1733,40 @@
|
||||
/* 标题预设卡片网格 */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(6, 52px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
background: var(--bg-secondary);
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
background: #404040;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: var(--primary-200);
|
||||
background: var(--bg-primary);
|
||||
border-color: #666;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
border-color: #409eff;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-title-preset-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active .xx-title-preset-card-label {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 样式按钮组 */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { smartMatchAssets, isAssetUsable } from "@/api/assets"
|
||||
|
||||
interface UseSmartMatchOptions {
|
||||
libraryId: string
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前模板的 segments,用于根据总时长计算 limit */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
/** 默认 limit(拿不到目标时长时的兜底上限) */
|
||||
const DEFAULT_LIMIT = 10
|
||||
/** 每个素材切片按 15 秒估算所需素材数 */
|
||||
const SECONDS_PER_ASSET = 15
|
||||
|
||||
/**
|
||||
* 根据模板 segments 计算所需素材数量上限。
|
||||
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
|
||||
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材);
|
||||
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
*/
|
||||
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
if (!segments || segments.length === 0) return DEFAULT_LIMIT
|
||||
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
|
||||
if (totalSeconds <= 0) return DEFAULT_LIMIT
|
||||
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
|
||||
return Math.max(1, Math.min(limit, 200))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,6 +40,7 @@ export function useSmartMatch({
|
||||
libraryId,
|
||||
materials,
|
||||
onSmartSelectedIdsChange,
|
||||
templateSegments,
|
||||
}: UseSmartMatchOptions) {
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
@@ -42,12 +65,14 @@ export function useSmartMatch({
|
||||
setSmartMatching(true)
|
||||
|
||||
try {
|
||||
// 根据目标视频时长计算合理的素材数量上限,避免"有几个选几个"
|
||||
const limit = computeLimitFromSegments(templateSegments)
|
||||
|
||||
// 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤)
|
||||
// items 已在 API 层归一化(兼容后端 {asset, score} 包装结构);
|
||||
// 这里再过滤一遍无 id/已用尽项,杜绝 undefined id 流入预览链路
|
||||
const result = await smartMatchAssets(libraryId)
|
||||
const result = await smartMatchAssets(libraryId, limit)
|
||||
// 兜底过滤:id 为空或不可用的素材不参与匹配(smartMatchAssets 已做归一化,这里双保险)
|
||||
const matched = (result.items ?? []).filter((a) => !!a?.id && isAssetUsable(a))
|
||||
const matchedIds = matched.map((a) => a.id)
|
||||
const matchedIds = matched.map((a: AssetItem) => a.id)
|
||||
|
||||
if (matchedIds.length > 0) {
|
||||
onSmartSelectedIdsChange(matchedIds)
|
||||
@@ -67,7 +92,7 @@ export function useSmartMatch({
|
||||
} finally {
|
||||
setSmartMatching(false)
|
||||
}
|
||||
}, [libraryId, materials.items, onSmartSelectedIdsChange])
|
||||
}, [libraryId, materials.items, onSmartSelectedIdsChange, templateSegments])
|
||||
|
||||
/* ── 换一批 = 重新触发智能匹配 ── */
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
|
||||
@@ -233,7 +233,7 @@ export function useCanvasPlayer(
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
|
||||
@@ -27,6 +27,8 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
|
||||
@@ -117,6 +117,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
...(props.titleSettings.position === "custom" &&
|
||||
props.titleSettings.posX != null &&
|
||||
props.titleSettings.posY != null
|
||||
? {
|
||||
pos_x: Math.round(props.titleSettings.posX),
|
||||
pos_y: Math.round(props.titleSettings.posY),
|
||||
}
|
||||
: {}),
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
|
||||
@@ -51,6 +51,7 @@ export function useStep2Materials({
|
||||
libraryId: selectedLibraryId,
|
||||
materials: selectableMaterials,
|
||||
onSmartSelectedIdsChange,
|
||||
templateSegments,
|
||||
})
|
||||
|
||||
/* ── 自动触发智能匹配:选择视频库后自动调用 ── */
|
||||
|
||||
@@ -46,7 +46,16 @@ export function useTitleStyleUpdaters({
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
// 切回三档预设时清掉自定义坐标
|
||||
onTitleSettingsChange({ ...titleSettings, position, posX: null, posY: null })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
/** 拖拽更新自定义位置(由预览播放器调用) */
|
||||
const updateTitlePosition = useCallback(
|
||||
(posX: number, posY: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position: "custom", posX, posY })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
@@ -111,6 +120,7 @@ export function useTitleStyleUpdaters({
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateTitlePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface TitleSettings {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
|
||||
posX: number | null
|
||||
posY: number | null
|
||||
}
|
||||
|
||||
/* ── 智能匹配结果 ── */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 列表查询 → hooks/useProductList
|
||||
@@ -8,15 +8,12 @@
|
||||
* 筛选栏 → components/ProductFilterBar
|
||||
* 批量操作栏 → components/ProductBatchBar
|
||||
* 空状态 → components/ProductEmptyState
|
||||
* 产品卡片 → components/ProductCard
|
||||
* 视频播放 → components/VideoPlayer
|
||||
* 产品卡片 → components/ProductCard(内联视频播放)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "./types"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
import { ProductFilterBar } from "./components/ProductFilterBar"
|
||||
import { ProductBatchBar } from "./components/ProductBatchBar"
|
||||
import { ProductEmptyState } from "./components/ProductEmptyState"
|
||||
@@ -53,13 +50,9 @@ const ProductLibrary: React.FC = () => {
|
||||
clearSelection,
|
||||
} = useProductList()
|
||||
|
||||
/* 播放器 */
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
|
||||
|
||||
const {
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
@@ -71,7 +64,7 @@ const ProductLibrary: React.FC = () => {
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
setPlayingProduct,
|
||||
setPlayingProduct: () => {}, // 不再使用弹窗播放
|
||||
})
|
||||
|
||||
// ── Loading 状态 ──
|
||||
@@ -146,7 +139,6 @@ const ProductLibrary: React.FC = () => {
|
||||
isSelected={selectedIds.has(product.id)}
|
||||
batchMode={batchMode}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onPlay={setPlayingProduct}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
@@ -158,17 +150,6 @@ const ProductLibrary: React.FC = () => {
|
||||
) : (
|
||||
<ProductEmptyState type="empty" />
|
||||
)}
|
||||
|
||||
{/* 视频播放弹窗 */}
|
||||
{playingProduct && (
|
||||
<VideoPlayer
|
||||
product={playingProduct}
|
||||
onClose={() => setPlayingProduct(null)}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react"
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
CheckOutlined,
|
||||
@@ -7,17 +7,17 @@ import {
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
CloudUploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
import { formatTime } from "../utils"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductItem
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
@@ -30,7 +30,6 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
@@ -38,17 +37,39 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [aspectRatio, setAspectRatio] = useState<string | null>(null)
|
||||
const [hasVideo, setHasVideo] = useState(!!product.videoUrl)
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
/** 视频元数据加载后获取实际比例 */
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
|
||||
setAspectRatio(`${v.videoWidth} / ${v.videoHeight}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 视频播放/暂停结束事件 */
|
||||
const handlePlay = useCallback(() => setIsPlaying(true), [])
|
||||
const handlePause = useCallback(() => setIsPlaying(false), [])
|
||||
|
||||
/** 视频出错时降级为缩略图 */
|
||||
const handleVideoError = useCallback(() => setHasVideo(false), [])
|
||||
|
||||
/** 点击缩略图区域:有视频则内联播放,否则不响应 */
|
||||
const handleThumbClick = () => {
|
||||
if (batchMode) return
|
||||
if (!hasVideo || !videoRef.current) return
|
||||
const v = videoRef.current
|
||||
if (v.paused) {
|
||||
v.play()
|
||||
} else {
|
||||
onPlay(product)
|
||||
v.pause()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +79,24 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
/** 卡片容器点击(非批量模式下不再触发弹窗播放) */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 组件卸载时暂停视频 */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
return () => {
|
||||
v?.pause()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 缩略图区域 style:有实际比例则用实际比例,否则默认 16:9 */
|
||||
const thumbStyle: React.CSSProperties = aspectRatio ? { aspectRatio } : { aspectRatio: "16 / 9" }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
@@ -102,21 +141,61 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
{/* 视频/缩略图区域 */}
|
||||
<div className="xx-product-thumb" style={thumbStyle}>
|
||||
{hasVideo ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="xx-product-thumb-video"
|
||||
src={product.videoUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handlePause}
|
||||
onError={handleVideoError}
|
||||
controls={isPlaying}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleThumbClick()
|
||||
}}
|
||||
/>
|
||||
{/* 未播放时显示播放按钮覆盖层 */}
|
||||
{!isPlaying && (
|
||||
<div className="xx-product-play" onClick={handleThumbClick}>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
<>
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-product-thumb-bg"
|
||||
style={{
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "48px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
@@ -131,11 +210,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
<div className="xx-product-meta-grid">
|
||||
<span className="xx-product-meta-item">分辨率:{product.resolution || "-"}</span>
|
||||
<span className="xx-product-meta-item">
|
||||
时长:{product.duration > 0 ? formatTime(product.duration) : "-"}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-product-meta-item">大小:{formatSize(product.fileSize)}</span>
|
||||
<span
|
||||
className={`xx-product-meta-item xx-product-dup-rate${
|
||||
product.duplicateRate > 0 ? ` ${dupClass}` : ""
|
||||
}`}
|
||||
>
|
||||
查重率:{product.duplicateRate != null ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -91,8 +91,6 @@
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-xs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: var(--transition-all);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
@@ -137,7 +135,7 @@
|
||||
============================================================ */
|
||||
.xx-products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -187,8 +185,6 @@
|
||||
height: 22px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
border-radius: var(--radius-xs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
@@ -260,13 +256,10 @@
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 220px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
background: var(--color-gray-950);
|
||||
}
|
||||
|
||||
.xx-product-thumb-bg {
|
||||
@@ -276,23 +269,41 @@
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* 内联视频播放器(cover 填满容器,竖屏视频不留左右空白) */
|
||||
.xx-product-thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-product-thumb-video[controls] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-product-play {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-md);
|
||||
transition: var(--transition-all);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-product-card:hover .xx-product-play {
|
||||
background: var(--primary-color);
|
||||
transform: scale(1.1);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
/* 时长标签 */
|
||||
@@ -312,7 +323,7 @@
|
||||
|
||||
/* 卡片信息区 */
|
||||
.xx-product-info {
|
||||
padding: 14px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
@@ -335,6 +346,21 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 卡片信息网格:分辨率/时长 一行,大小/查重率 一行 */
|
||||
.xx-product-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-product-meta-item {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.xx-product-status {
|
||||
padding: 2px 10px;
|
||||
@@ -479,8 +505,6 @@
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
animation: player-fade-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@@ -519,8 +543,6 @@
|
||||
background: var(--color-gray-950);
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 60vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.xx-player-video-wrap video {
|
||||
@@ -542,8 +564,6 @@
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--text-inverse);
|
||||
font-size: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition-all);
|
||||
@@ -640,8 +660,6 @@
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition-all);
|
||||
|
||||
@@ -308,6 +308,85 @@ class VideoDeduplicator:
|
||||
|
||||
return sum(similarities) / len(similarities) if similarities else 0.0
|
||||
|
||||
def compute_duplicate_rate(
|
||||
self,
|
||||
fingerprint: VideoFingerprint,
|
||||
project_id: str,
|
||||
current_video_id: str | None,
|
||||
session: Session,
|
||||
*,
|
||||
user_id: str = "",
|
||||
) -> float:
|
||||
"""计算当前视频与用户库内已有视频的最高相似度百分比。
|
||||
|
||||
优先按 user_id 全局比较(跨项目),user_id 为空时回退到项目级比较。
|
||||
遍历最近 200 个其他有指纹的视频,对每个计算相似度:
|
||||
- MD5 精确匹配 → 100%
|
||||
- pHash 相似度 → (1.0 - avg_distance / 64) * 100
|
||||
取最高值作为 duplicate_rate(0~100)。
|
||||
如果没有其他视频可比较,返回 0.0。
|
||||
|
||||
Args:
|
||||
fingerprint: 当前视频的指纹
|
||||
project_id: 项目 ID(user_id 为空时的回退范围)
|
||||
current_video_id: 当前视频 ID(排除自身,可为 None)
|
||||
session: 数据库会话
|
||||
user_id: 用户 ID(优先按用户全局比较)
|
||||
|
||||
Returns:
|
||||
duplicate_rate: 0~100 的浮点数
|
||||
"""
|
||||
# 限制查询最近 200 个视频,避免大库内存溢出
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
# 优先按 user_id 全局比较(跨项目),否则回退到项目级
|
||||
if user_id:
|
||||
query = session.query(GeneratedVideoModel).filter(
|
||||
GeneratedVideoModel.user_id == user_id,
|
||||
)
|
||||
logger.debug("compute_duplicate_rate: user-level scope user_id=%s", user_id)
|
||||
else:
|
||||
query = session.query(GeneratedVideoModel).filter(
|
||||
GeneratedVideoModel.project_id == project_id,
|
||||
)
|
||||
logger.debug("compute_duplicate_rate: project-level fallback project_id=%s", project_id)
|
||||
|
||||
# 排除当前视频自身(记录可能已写入 DB,必须在查询层排除)
|
||||
if current_video_id:
|
||||
query = query.filter(GeneratedVideoModel.id != current_video_id)
|
||||
|
||||
recent_models = query.order_by(GeneratedVideoModel.generated_at.desc()).limit(200).all()
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
existing_videos = [video_repo._to_domain(m) for m in recent_models]
|
||||
|
||||
max_similarity = 0.0
|
||||
for existing in existing_videos:
|
||||
if current_video_id and existing.id == current_video_id:
|
||||
continue
|
||||
if not existing.video_fingerprint:
|
||||
continue
|
||||
|
||||
ef = existing.video_fingerprint
|
||||
|
||||
# MD5 精确匹配 → 100%
|
||||
if fingerprint.md5 == ef.get("md5"):
|
||||
return 100.0
|
||||
|
||||
# pHash 相似度
|
||||
existing_phashes = ef.get("keyframe_phashes", [])
|
||||
if not existing_phashes or not fingerprint.keyframe_phashes:
|
||||
continue
|
||||
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 64
|
||||
similarity = (1.0 - avg_distance / 64) * 100
|
||||
max_similarity = max(max_similarity, similarity)
|
||||
|
||||
return round(max(max_similarity, 0.0), 2)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3, name="worker.check_duplicate")
|
||||
def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
|
||||
@@ -121,6 +121,21 @@ def create_video_record_and_dedup(
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
# 计算重复率百分比(与项目内所有已有视频对比取最高相似度)
|
||||
try:
|
||||
dup_rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
video_id,
|
||||
session,
|
||||
user_id=user_id,
|
||||
)
|
||||
generated_video.duplicate_rate = dup_rate
|
||||
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
|
||||
except Exception as rate_err:
|
||||
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
|
||||
generated_video.duplicate_rate = None
|
||||
|
||||
video_repo.update(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
|
||||
@@ -200,6 +200,22 @@ class UnifiedRenderService:
|
||||
|
||||
# 3. 计算视频总时长(用于字幕显示时长)
|
||||
video_duration = self._estimate_total_duration(layers)
|
||||
# Debug: 输出各图层时长明细
|
||||
for layer in layers:
|
||||
layer_total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in layer.clips)
|
||||
clip_details = [
|
||||
f"{c.clip_id}(dur={c.duration:.3f},actual={c.actual_duration:.3f},speed={getattr(c, 'playback_speed', 1.0):.4f})"
|
||||
for c in layer.clips
|
||||
]
|
||||
logger.info(
|
||||
"[debug] layer=%s clips=%d total=%.3f transition_duration=%.3f details=%s",
|
||||
layer.role,
|
||||
len(layer.clips),
|
||||
layer_total,
|
||||
self.transition_duration,
|
||||
", ".join(clip_details),
|
||||
)
|
||||
logger.info("[debug] estimated video_duration=%.3f", video_duration)
|
||||
|
||||
# 3.5 TTS 配音生成(如果配置了)
|
||||
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
|
||||
@@ -1422,6 +1438,7 @@ class UnifiedRenderService:
|
||||
if trim_segments and len(trim_segments) > 1:
|
||||
# 多段裁剪:展开为多个 clip
|
||||
resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration)
|
||||
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
for i, seg in enumerate(resolved_segments):
|
||||
# 每个段生成一个独立的 ResolvedClip
|
||||
seg_clip_id = f"{clip.id}_seg_{seg.segment_id}"
|
||||
@@ -1429,6 +1446,19 @@ class UnifiedRenderService:
|
||||
seg_start = seg.trim.start_time
|
||||
seg_duration = seg.trim.duration
|
||||
|
||||
# 多段裁剪:如果段的时长超过素材实际时长,减速补偿
|
||||
seg_speed = configured_speed
|
||||
if actual_duration > 0 and seg_duration > actual_duration + 0.05:
|
||||
seg_speed = max(0.25, round(configured_speed * actual_duration / seg_duration, 4))
|
||||
logger.info(
|
||||
"[debug] multi-seg clip=%s seg=%s duration=%.3f actual=%.3f → speed=%.4f",
|
||||
clip.id,
|
||||
seg.segment_id,
|
||||
seg_duration,
|
||||
actual_duration,
|
||||
seg_speed,
|
||||
)
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=seg_clip_id,
|
||||
asset_id=asset_id,
|
||||
@@ -1439,7 +1469,7 @@ class UnifiedRenderService:
|
||||
duration=seg_duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||||
playback_speed=seg_speed,
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
@@ -1461,6 +1491,7 @@ class UnifiedRenderService:
|
||||
effective_trim: TrimConfig | None = None
|
||||
final_start = clip.start_time
|
||||
final_duration = clip.duration
|
||||
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
|
||||
if trim_config is not None and actual_duration > 0:
|
||||
effective_trim = trim_config.validate_and_resolve(actual_duration)
|
||||
@@ -1474,6 +1505,25 @@ class UnifiedRenderService:
|
||||
final_start = 0.0
|
||||
final_duration = actual_duration
|
||||
|
||||
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
|
||||
# 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出
|
||||
if actual_duration > 0 and final_duration > actual_duration + 0.05:
|
||||
compensated_speed = actual_duration / final_duration
|
||||
# 保留用户设置的速度(如果已减速则叠加)
|
||||
final_speed = configured_speed * compensated_speed
|
||||
# 下限 0.25x
|
||||
final_speed = max(0.25, round(final_speed, 4))
|
||||
logger.info(
|
||||
"[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)",
|
||||
clip.id,
|
||||
final_duration,
|
||||
actual_duration,
|
||||
final_speed,
|
||||
configured_speed,
|
||||
)
|
||||
else:
|
||||
final_speed = configured_speed
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=clip.id,
|
||||
asset_id=asset_id,
|
||||
@@ -1484,13 +1534,25 @@ class UnifiedRenderService:
|
||||
duration=final_duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||||
playback_speed=final_speed,
|
||||
config=clip_config,
|
||||
actual_duration=actual_duration,
|
||||
trim_config=effective_trim,
|
||||
)
|
||||
resolved.append(rc)
|
||||
|
||||
# Debug日志:记录每个clip的时长信息
|
||||
eff_dur = _clip_effective_duration_pure(final_duration, actual_duration)
|
||||
logger.info(
|
||||
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
|
||||
clip.id,
|
||||
final_duration,
|
||||
actual_duration,
|
||||
eff_dur,
|
||||
final_speed,
|
||||
final_start,
|
||||
)
|
||||
|
||||
# 按 order 排序
|
||||
resolved.sort(key=lambda c: c.order)
|
||||
return resolved
|
||||
@@ -1683,7 +1745,7 @@ class UnifiedRenderService:
|
||||
if d > 0:
|
||||
layer_dur = d
|
||||
break
|
||||
xfade_filter, _ = self._transition_engine.build_xfade_chain(
|
||||
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
|
||||
clip_durations=layer_durations,
|
||||
clip_video_labels=layer_labels,
|
||||
transitions=layer_transitions,
|
||||
@@ -1692,6 +1754,13 @@ class UnifiedRenderService:
|
||||
)
|
||||
if xfade_filter:
|
||||
filter_parts.append(xfade_filter)
|
||||
logger.info(
|
||||
"[unified-render] layer=%s xfade: clips=%d durations=%s estimated_dur=%.3f",
|
||||
layer.role,
|
||||
len(layer_labels),
|
||||
[round(d, 3) for d in layer_durations],
|
||||
xfade_estimated_dur,
|
||||
)
|
||||
layer_output_labels[layer.role] = out_label
|
||||
|
||||
# Step 3: 合成各层
|
||||
@@ -1915,8 +1984,14 @@ class UnifiedRenderService:
|
||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
|
||||
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration,
|
||||
而非 min(duration, actual_duration)。
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
|
||||
"""
|
||||
speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
# 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration
|
||||
if speed < 1.0 - 1e-6 and clip.duration > 0:
|
||||
return clip.duration
|
||||
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
|
||||
|
||||
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +30,14 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-bake ffmpeg: unit-tests run in fresh containers each time; installing ffmpeg
|
||||
# on every job cost ~24 min (apt update + hundreds of codec deps). Bake it into the
|
||||
# image so step_install_ffmpeg.sh detects it and exits instantly.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& ffmpeg -version | head -1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-install base deps (layer cache)
|
||||
COPY requirements-base.txt ./
|
||||
RUN python -m venv "$VIRTUAL_ENV" \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Build stage
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
ARG SOURCE_HASH=""
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
@@ -18,8 +19,10 @@ COPY apps/web/ ./
|
||||
|
||||
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
|
||||
# node_modules直接使用镜像中已安装的(layer缓存保证完整性)
|
||||
# SOURCE_HASH 变化时强制重新执行(防止 buildkit 幽灵缓存命中)
|
||||
RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& echo "SOURCE_HASH=${SOURCE_HASH}" > .cache_bust \
|
||||
&& ./node_modules/.bin/tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& ./node_modules/.bin/vite build
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.dumps(video.video_fingerprint) if video.video_fingerprint else None,
|
||||
is_duplicate=video.is_duplicate,
|
||||
duplicate_of=video.duplicate_of,
|
||||
duplicate_rate=video.duplicate_rate,
|
||||
generated_at=video.generated_at,
|
||||
created_at=video.created_at,
|
||||
)
|
||||
@@ -60,6 +61,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
duplicate_rate=getattr(model, "duplicate_rate", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -74,6 +76,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
model.video_fingerprint = json.dumps(video.video_fingerprint) if video.video_fingerprint else None
|
||||
model.is_duplicate = video.is_duplicate
|
||||
model.duplicate_of = video.duplicate_of
|
||||
model.duplicate_rate = video.duplicate_rate
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return video
|
||||
@@ -204,6 +207,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
duplicate_rate=getattr(model, "duplicate_rate", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -338,6 +338,7 @@ class GeneratedVideoModel(Base):
|
||||
video_fingerprint = Column(Text, nullable=True)
|
||||
is_duplicate = Column(Boolean, nullable=False, default=False)
|
||||
duplicate_of = Column(String(36), nullable=True)
|
||||
duplicate_rate = Column(Float, nullable=True)
|
||||
|
||||
|
||||
class TitleLibraryModel(Base):
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""SQLAlchemy implementation of TemplateRepository."""
|
||||
"""SQLAlchemy implementation of TemplateRepository.
|
||||
|
||||
模板 segments 数据源已统一为 template_clip_configs 表。
|
||||
读取时优先 template_clip_configs,回退 template_segments(兼容历史数据)。
|
||||
写入全部走 template_clip_configs。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
EditPlanModel,
|
||||
TemplateCategoryModel,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
@@ -47,27 +53,38 @@ class SQLAlchemyTemplateRepository:
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.filter(TemplateModel.name.like(like_pattern))
|
||||
if tag:
|
||||
# JSON 数组包含指定标签(MySQL JSON_CONTAINS / SQLite json_each 兼容写法用 LIKE)
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
models = query.order_by(TemplateModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
templates = [self._model_to_entity(m) for m in models]
|
||||
# 批量加载所有 segments,避免 N+1 查询
|
||||
# 批量加载 segments —— 优先 template_clip_configs
|
||||
if templates:
|
||||
template_ids = [t.id for t in templates]
|
||||
seg_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
clip_models = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
# 按 template_id 分组
|
||||
seg_map: dict[str, list] = {}
|
||||
for sm in seg_models:
|
||||
seg_map.setdefault(sm.template_id, []).append(
|
||||
self._segment_model_to_entity(sm),
|
||||
clip_map: dict[str, list] = {}
|
||||
for cm in clip_models:
|
||||
clip_map.setdefault(cm.template_id, []).append(
|
||||
self._clip_config_to_segment(cm),
|
||||
)
|
||||
# 对没有 clip_configs 的模板,回退读 template_segments
|
||||
missing_ids = [t.id for t in templates if t.id not in clip_map]
|
||||
if missing_ids:
|
||||
old_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(missing_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
for om in old_models:
|
||||
clip_map.setdefault(om.template_id, []).append(
|
||||
self._segment_model_to_entity(om),
|
||||
)
|
||||
for t in templates:
|
||||
t.segments = seg_map.get(t.id, [])
|
||||
t.segments = clip_map.get(t.id, [])
|
||||
return templates
|
||||
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
@@ -100,7 +117,6 @@ class SQLAlchemyTemplateRepository:
|
||||
is_active=template.is_active,
|
||||
)
|
||||
self.session.add(model)
|
||||
# flush 而非 commit,让 create + create_segments 在同一事务中提交
|
||||
self.session.flush()
|
||||
self.session.refresh(model)
|
||||
result = self._model_to_entity(model)
|
||||
@@ -145,11 +161,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if model is None:
|
||||
return False
|
||||
model.is_active = False
|
||||
# 级联清理关联的 segments,避免孤儿数据
|
||||
self.session.query(TemplateSegmentModel).filter(
|
||||
TemplateSegmentModel.template_id == template_id,
|
||||
).delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
# 复用 delete_segments_by_template 清理两张表的关联数据
|
||||
self.delete_segments_by_template(template_id)
|
||||
return True
|
||||
|
||||
def count_by_user(
|
||||
@@ -172,7 +185,7 @@ class SQLAlchemyTemplateRepository:
|
||||
if keyword:
|
||||
query = query.filter(TemplateModel.name.like(f"%{keyword}%"))
|
||||
if tag:
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
return query.count()
|
||||
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template:
|
||||
@@ -181,9 +194,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if source is None:
|
||||
raise ValueError(f"Template {template_id} not found")
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
new_template = Template(
|
||||
id=new_id,
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=new_name,
|
||||
mode=source.mode,
|
||||
@@ -197,28 +209,22 @@ class SQLAlchemyTemplateRepository:
|
||||
)
|
||||
created = self.create(new_template)
|
||||
|
||||
# 复制 segments
|
||||
# 复用 create_segments 写入 template_clip_configs
|
||||
new_segments: List[TemplateSegment] = []
|
||||
for seg in source.segments:
|
||||
new_seg = TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=new_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
new_segments.append(
|
||||
TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=created.id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
)
|
||||
)
|
||||
new_segments.append(new_seg)
|
||||
model = TemplateSegmentModel(
|
||||
id=new_seg.id,
|
||||
template_id=new_seg.template_id,
|
||||
segment_order=new_seg.segment_order,
|
||||
duration_min=new_seg.duration_min,
|
||||
duration_max=new_seg.duration_max,
|
||||
material_type=new_seg.material_type,
|
||||
)
|
||||
self.session.add(model)
|
||||
if new_segments:
|
||||
self.create_segments(new_segments)
|
||||
else:
|
||||
self.session.commit()
|
||||
|
||||
created.segments = new_segments
|
||||
@@ -227,34 +233,58 @@ class SQLAlchemyTemplateRepository:
|
||||
# ── Segments ──
|
||||
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]:
|
||||
models = (
|
||||
"""优先从 template_clip_configs 读取,回退读 template_segments。"""
|
||||
clips = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
if clips:
|
||||
return [self._clip_config_to_segment(m) for m in clips]
|
||||
# 回退:旧表
|
||||
old = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
return [self._segment_model_to_entity(m) for m in models]
|
||||
return [self._segment_model_to_entity(m) for m in old]
|
||||
|
||||
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
|
||||
"""写入 template_clip_configs 表。material_type 存入 config JSON。"""
|
||||
for seg in segments:
|
||||
model = TemplateSegmentModel(
|
||||
config = {"material_type": seg.material_type} if seg.material_type else {}
|
||||
model = TemplateClipConfigModel(
|
||||
id=seg.id,
|
||||
template_id=seg.template_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
min_duration=seg.duration_min,
|
||||
max_duration=seg.duration_max,
|
||||
text_template="",
|
||||
material_requirements={},
|
||||
transition_effect="cut",
|
||||
config=config,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return segments
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == template_id).delete()
|
||||
"""删除两张表中的 segments 数据,返回删除总数。"""
|
||||
c1 = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
c2 = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
return c1 + c2
|
||||
|
||||
# ── Categories ──
|
||||
|
||||
@@ -366,6 +396,23 @@ class SQLAlchemyTemplateRepository:
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clip_config_to_segment(model: TemplateClipConfigModel) -> TemplateSegment:
|
||||
"""将 TemplateClipConfigModel 转换为 TemplateSegment 域实体。"""
|
||||
material_type = None
|
||||
if model.config and isinstance(model.config, dict):
|
||||
material_type = model.config.get("material_type")
|
||||
return TemplateSegment(
|
||||
id=model.id,
|
||||
template_id=model.template_id,
|
||||
segment_order=model.order,
|
||||
duration_min=model.min_duration,
|
||||
duration_max=model.max_duration,
|
||||
material_type=material_type,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
|
||||
return TemplateCategory(
|
||||
|
||||
@@ -256,6 +256,42 @@ def _wrap_title_text(
|
||||
return "\\N".join(wrapped_segments)
|
||||
|
||||
|
||||
def _parse_title_position(
|
||||
title_config: dict[str, Any],
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
) -> tuple[int, int] | None:
|
||||
"""解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。
|
||||
|
||||
要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height]
|
||||
闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。
|
||||
|
||||
Args:
|
||||
title_config: 标题配置 dict
|
||||
video_width: PlayResX(视频宽度像素)
|
||||
video_height: PlayResY(视频高度像素)
|
||||
|
||||
Returns:
|
||||
(x, y) 整数坐标,或 None 表示不使用自由位置
|
||||
"""
|
||||
if "pos_x" not in title_config or "pos_y" not in title_config:
|
||||
return None
|
||||
raw_x = title_config["pos_x"]
|
||||
raw_y = title_config["pos_y"]
|
||||
# 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True)
|
||||
# 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退
|
||||
if isinstance(raw_x, bool) or isinstance(raw_y, bool):
|
||||
return None
|
||||
if not isinstance(raw_x, int) or not isinstance(raw_y, int):
|
||||
return None
|
||||
x, y = raw_x, raw_y
|
||||
if video_width <= 0 or video_height <= 0:
|
||||
return None
|
||||
if not (0 <= x <= video_width and 0 <= y <= video_height):
|
||||
return None
|
||||
return (x, y)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
*,
|
||||
video_width: int,
|
||||
@@ -343,7 +379,16 @@ def build_ass_content(
|
||||
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
||||
)
|
||||
|
||||
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
|
||||
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
|
||||
# pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。
|
||||
# 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心),
|
||||
# Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退
|
||||
# position → alignment 三档逻辑,现有输出保持一字节不变。
|
||||
title_pos = _parse_title_position(title_config, video_width, video_height)
|
||||
|
||||
title_alignment = 5 if title_pos is not None else position_to_ass_alignment(
|
||||
title_config.get("position", "top")
|
||||
)
|
||||
|
||||
styles.append(
|
||||
build_ass_style(
|
||||
@@ -370,6 +415,10 @@ def build_ass_content(
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
|
||||
if title_pos is not None:
|
||||
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
|
||||
|
||||
events.append(
|
||||
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ class GeneratedVideo:
|
||||
video_fingerprint: dict[str, Any] | None = None
|
||||
is_duplicate: bool = False
|
||||
duplicate_of: str | None = None
|
||||
duplicate_rate: float | None = None
|
||||
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
# 素材选取排序时注入的随机噪声上限(分)。
|
||||
# score_asset 综合得分范围为 0-100,噪声 0~20 意味着:
|
||||
# - 素材间得分差距 > 20 分时,排名不受影响(质量差异显著的素材保持稳定优先级)
|
||||
# - 得分接近(差距 <= 20 分)的素材排名会随机浮动,使每次生成选出的素材组合不同,
|
||||
# 从素材组合层面降低成片重复率;排名靠后的低分素材也有机会入选。
|
||||
SCORE_RANDOM_NOISE_MAX = 20.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartMatchResult:
|
||||
|
||||
@@ -150,8 +150,11 @@ def build_xfade_filter_chain(
|
||||
else:
|
||||
first_input_dur = cumulative - total_transition
|
||||
|
||||
# 原始 offset 计算
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
# 正确的 offset 计算:offset 应相对于累积输出时长
|
||||
# offset = 累积输出中,转场开始的时间点
|
||||
# = first_input_dur - transition_duration
|
||||
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
|
||||
offset = max(0.0, first_input_dur - transition_duration)
|
||||
|
||||
# 安全钳制:offset + td 不能超过第一个输入的时长
|
||||
available = max(0.0, first_input_dur - offset)
|
||||
|
||||
@@ -119,6 +119,70 @@ class MediaKitClient:
|
||||
|
||||
return None
|
||||
|
||||
def detect_scene_changes(
|
||||
self,
|
||||
video_url: str,
|
||||
max_frames: int = 20,
|
||||
poll_interval: float = 2.0,
|
||||
max_poll_attempts: int = 30,
|
||||
) -> Optional[List[float]]:
|
||||
"""检测视频场景切换点,返回时间戳列表.
|
||||
|
||||
降级策略:
|
||||
1. 先尝试 SceneChange 策略
|
||||
2. SceneChange 失败(OOM等)→ 退回 TimeInterval(5秒间隔)
|
||||
3. MediaKit 不可用 → 返回 None
|
||||
|
||||
Returns:
|
||||
场景切换点时间戳列表,如 [0.0, 3.2, 7.8, 12.5]
|
||||
失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
logger.warning("MediaKit 未配置,跳过场景检测")
|
||||
return None
|
||||
|
||||
# 策略1:尝试 SceneChange
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SceneChange",
|
||||
max_frames=max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
# 策略2:SceneChange 失败 → 退回 TimeInterval(5秒间隔)
|
||||
if frames is None:
|
||||
logger.info("SceneChange 策略失败,降级为 TimeInterval(5秒间隔)")
|
||||
# 估算帧数:假设视频最长60秒,每5秒一帧
|
||||
ti_max_frames = max(max_frames, 12)
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="TimeInterval",
|
||||
max_frames=ti_max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
if frames is None:
|
||||
return None
|
||||
|
||||
# 从帧列表中提取 timestamp,排序
|
||||
timestamps = sorted({float(f.get("timestamp", 0.0)) for f in frames if "timestamp" in f})
|
||||
|
||||
if not timestamps:
|
||||
return None
|
||||
|
||||
# 始终在列表开头加 0.0(素材起始点)
|
||||
if timestamps[0] != 0.0:
|
||||
timestamps.insert(0, 0.0)
|
||||
|
||||
logger.info(
|
||||
"场景检测完成: video_url=%s scene_changes=%s",
|
||||
video_url[:80],
|
||||
timestamps,
|
||||
)
|
||||
return timestamps
|
||||
|
||||
def _submit_extract_task(
|
||||
self,
|
||||
video_url: str,
|
||||
|
||||
@@ -24,8 +24,10 @@ celery==5.4.0
|
||||
# 对象存储
|
||||
oss2==2.18.4
|
||||
|
||||
# HTTP 客户端
|
||||
# HTTP 客户端(pin 间接依赖防止版本漂移)
|
||||
httpx==0.27.2
|
||||
httpcore==1.0.7
|
||||
h2==4.1.0
|
||||
|
||||
# Prometheus monitoring
|
||||
prometheus-client==0.21.1
|
||||
|
||||
@@ -15,5 +15,6 @@ pytest-xdist==3.6.1
|
||||
diff-cover==8.0.3
|
||||
|
||||
# 资产质量评分依赖(与 requirements-worker.txt 保持一致)
|
||||
numpy==1.26.4
|
||||
scipy==1.13.1
|
||||
Pillow==10.4.0
|
||||
|
||||
@@ -28,15 +28,12 @@ if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+Security,Type Check+Migration 合并为 Python
|
||||
# 与 pr_auto_scan.py 的 REQUIRED_CONTEXTS_APPROVE 保持一致
|
||||
"CI/CD Pipeline / Validate - Style (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Security (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
@@ -153,4 +150,4 @@ done
|
||||
|
||||
echo
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
exit 0
|
||||
exit 0
|
||||
@@ -13,6 +13,7 @@ before="${GITHUB_EVENT_BEFORE:-}"
|
||||
after="${GITHUB_SHA:-}"
|
||||
repo="${GITHUB_REPOSITORY:-}"
|
||||
base="${GITHUB_API_URL:-}"
|
||||
ZERO="0000000000000000000000000000000000000000"
|
||||
|
||||
# Gitea Actions 中 push 事件的前一个 SHA 在 event payload 的 before 字段
|
||||
if [ -z "$before" ] && [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
||||
@@ -28,8 +29,76 @@ fi
|
||||
|
||||
echo "改动范围检测: before=${before:-<empty>} after=${after}"
|
||||
|
||||
# ── 安全回溯:确保 diff 基准覆盖所有未构建的改动 ──
|
||||
# 问题:concurrency 取消机制会导致前端改动被跳过。被取消的 push 的改动不会被
|
||||
# 后续 push 的 diff 覆盖到,因为 GITHUB_EVENT_BEFORE 只指向上一次 push 的 SHA。
|
||||
# 修复:查询最近一次**实际构建了 web 镜像**的成功 push run,用其 head_sha 作为
|
||||
# diff 基准。这样被取消/跳过的 run 的改动都会被包含在当前 diff 中。
|
||||
if [ -n "$before" ] && [ "$before" != "$ZERO" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
BRANCH="${GITHUB_REF_NAME:-}"
|
||||
if [ -n "$BRANCH" ]; then
|
||||
SAFE_BASE=$(python3 -c "
|
||||
import json, subprocess, sys
|
||||
|
||||
base = '${base}'
|
||||
repo = '${repo}'
|
||||
token = '${GITHUB_TOKEN}'
|
||||
branch = '${BRANCH}'
|
||||
cur_sha = '${after}'
|
||||
|
||||
def check_run(run_id):
|
||||
\"\"\"Check if this run actually built the web image.\"\"\"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['curl', '-sf', '--max-time', '10',
|
||||
'-H', f'Authorization: token {token}',
|
||||
f'{base}/repos/{repo}/actions/runs/{run_id}/jobs'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
jobs = json.loads(r.stdout).get('jobs', [])
|
||||
return any(
|
||||
'Build Staging Web' in j.get('name', '')
|
||||
and j.get('conclusion') == 'success'
|
||||
for j in jobs
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['curl', '-sf', '--max-time', '15',
|
||||
'-H', f'Authorization: token {token}',
|
||||
f'{base}/repos/{repo}/actions/runs?status=success&event=push&branch={branch}&per_page=30'],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
sys.exit(0)
|
||||
d = json.loads(r.stdout)
|
||||
runs = d.get('workflow_runs', []) if isinstance(d, dict) else d
|
||||
for run in runs:
|
||||
sha = run.get('head_sha', '')
|
||||
if sha and sha != cur_sha:
|
||||
if check_run(run['id']):
|
||||
print(sha)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$SAFE_BASE" ] && [ "$SAFE_BASE" != "$before" ]; then
|
||||
echo "🔒 安全回溯: 使用最近实际构建 web 的 commit ${SAFE_BASE:0:8} 替代 before=${before:0:8}"
|
||||
before="$SAFE_BASE"
|
||||
elif [ -z "$SAFE_BASE" ]; then
|
||||
echo "⚠️ 未找到历史成功构建 web 的 push run,保守走全量构建"
|
||||
echo "skip_backend=false" >> "$OUTPUT"
|
||||
echo "skip_frontend=false" >> "$OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
FILES=""
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
if [ -n "$before" ] && [ "$before" != "$ZERO" ]; then
|
||||
# Gitea 1.26.x compare API 的顶层 files 字段不填充(始终为空),
|
||||
# 但响应里每个 commit 条目自带的 files 完整可用;聚合区间内所有提交的 files 即可。
|
||||
API_URL="${base}/repos/${repo}/compare/${before}...${after}?per_page=300"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=CI Transient Fault Auto-Retry
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# 安全设置
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/act-runner-docker/ci-retry-state /var/log
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
# Token 从环境文件加载(不要用明文写在 unit 里)
|
||||
EnvironmentFile=/opt/act-runner-docker/ci-retry-state/env
|
||||
ExecStart=/opt/act-runner-docker/ci-retry-state/ci_transient_retry.sh
|
||||
Executable
+305
@@ -0,0 +1,305 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# CI 瞬态故障自动重试脚本
|
||||
# ============================================================================
|
||||
#
|
||||
# 解决什么问题:
|
||||
# 当某个 runner 出现瞬态故障(磁盘满、docker 挂掉、网络抖动等),分配到该
|
||||
# runner 的 job 会在 "Set up job" / "Checkout code" 阶段就失败。这种失败
|
||||
# 与代码无关,换到其他 runner 重跑就能通过,但 CI 没有内置重试机制,只能
|
||||
# 人工干预。
|
||||
#
|
||||
# 检测逻辑:
|
||||
# 1. 查询最近 30 分钟内完成的 workflow runs
|
||||
# 2. 找到 conclusion=failure 的 run
|
||||
# 3. 检查失败 run 中的 jobs:
|
||||
# a. 失败的 job 的第一个 step 必须是 "Checkout code" 或 "Set up job"
|
||||
# 且 conclusion=failure(说明 runner 环境有问题,不是代码问题)
|
||||
# b. 同一 run 中至少有 2 个成功的 job(排除代码本身全挂的情况)
|
||||
# c. 失败 job 数量 < 总 job 数量的 50%(多数成功=瞬态故障)
|
||||
# 4. 满足以上条件 → 判定为瞬态故障 → 自动 re-run 整个 workflow
|
||||
#
|
||||
# 安全措施:
|
||||
# - 每个 run 最多自动重试 1 次(通过状态文件跟踪,避免死循环)
|
||||
# - 状态文件 24 小时后自动清理
|
||||
# - 所有操作记录日志,便于审计
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/ci/ci_transient_retry.sh # 正常运行
|
||||
# ./scripts/ci/ci_transient_retry.sh --dry-run # 只检查不重试
|
||||
# ./scripts/ci/ci_transient_retry.sh --verbose # 详细日志输出
|
||||
#
|
||||
# 环境变量:
|
||||
# GITEA_API_TOKEN - Gitea API token(必须设置)
|
||||
# GITEA_API_URL - Gitea API 基础地址(默认 https://git.xiaoxiajianji.com/api/v1)
|
||||
# GITEA_REPO - 仓库路径(默认 xiaoxia/xiaoxia-saas)
|
||||
# RETRY_STATE_DIR - 重试状态目录(默认 /opt/act-runner-docker/ci-retry-state)
|
||||
# LOG_FILE - 日志文件(默认 /var/log/ci-auto-retry.log)
|
||||
#
|
||||
# 部署方式:
|
||||
# 1. 将本脚本复制到 CI 服务器(如 /opt/act-runner-docker/ci-retry-state/)
|
||||
# 2. 创建 env 文件: echo 'GITEA_API_TOKEN=xxx' > /opt/act-runner-docker/ci-retry-state/env
|
||||
# 3. 安装 systemd timer:
|
||||
# cp scripts/ci/ci_transient_retry.{service,timer} /etc/systemd/system/
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable --now ci_transient_retry.timer
|
||||
# 或用 crontab:
|
||||
# */5 * * * * GITEA_API_TOKEN=xxx bash /opt/act-runner-docker/ci-retry-state/ci_transient_retry.sh
|
||||
# ============================================================================
|
||||
set -eu
|
||||
|
||||
# ---- 配置 ----
|
||||
GITEA_API_URL="${GITEA_API_URL:-https://git.xiaoxiajianji.com/api/v1}"
|
||||
GITEA_REPO="${GITEA_REPO:-xiaoxia/xiaoxia-saas}"
|
||||
RETRY_STATE_DIR="${RETRY_STATE_DIR:-/opt/act-runner-docker/ci-retry-state}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/ci-auto-retry.log}"
|
||||
WINDOW_MINUTES=30 # 检查最近 N 分钟内的 run
|
||||
MAX_RETRY_PER_RUN=1 # 每个 run 最多重试次数
|
||||
STATE_TTL_HOURS=24 # 状态文件过期时间(小时)
|
||||
MIN_SUCCESS_JOBS=2 # 至少 N 个 job 成功才算瞬态故障
|
||||
MAX_FAIL_RATIO=50 # 失败 job 占比上限(%)
|
||||
|
||||
# ---- 参数解析 ----
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--verbose) VERBOSE=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- 日志 ----
|
||||
log() {
|
||||
local level="$1"; shift
|
||||
local ts
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
local msg="[$ts] [$level] $*"
|
||||
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
|
||||
if [ "$level" = "ERROR" ] || [ "$level" = "WARN" ] || $VERBOSE || $DRY_RUN; then
|
||||
echo "$msg"
|
||||
fi
|
||||
}
|
||||
log_info() { log INFO "$@"; }
|
||||
log_warn() { log WARN "$@"; }
|
||||
log_error() { log ERROR "$@"; }
|
||||
log_debug() { $VERBOSE && log DEBUG "$@" || true; }
|
||||
|
||||
# ---- 前置检查 ----
|
||||
if [ -z "${GITEA_API_TOKEN:-}" ]; then
|
||||
log_error "GITEA_API_TOKEN 未设置,退出"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RETRY_STATE_DIR" 2>/dev/null || true
|
||||
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
|
||||
|
||||
API_BASE="${GITEA_API_URL}/repos/${GITEA_REPO}/actions"
|
||||
|
||||
log_info "========== CI 瞬态故障检测开始 =========="
|
||||
$DRY_RUN && log_info "[DRY-RUN 模式] 不会实际触发重试"
|
||||
|
||||
# ---- 清理过期状态文件 ----
|
||||
find "$RETRY_STATE_DIR" -name "*.retry" -mmin "+$((STATE_TTL_HOURS * 60))" -delete 2>/dev/null || true
|
||||
|
||||
# ---- 临时文件(用 mktemp 避免引号/转义问题) ----
|
||||
TMPDIR_WORK=$(mktemp -d)
|
||||
trap "rm -rf $TMPDIR_WORK" EXIT
|
||||
|
||||
# ---- 查询最近完成的 runs ----
|
||||
curl -sf -H "Authorization: token ${GITEA_API_TOKEN}" \
|
||||
"${API_BASE}/runs?status=completed&limit=20" > "${TMPDIR_WORK}/runs.json" 2>/dev/null || echo '[]' > "${TMPDIR_WORK}/runs.json"
|
||||
|
||||
# ---- 主分析逻辑(全部用 python3,避免 bash JSON 处理陷阱) ----
|
||||
python3 - "$TMPDIR_WORK" "$API_BASE" "$GITEA_API_TOKEN" "$RETRY_STATE_DIR" \
|
||||
"$WINDOW_MINUTES" "$MIN_SUCCESS_JOBS" "$MAX_FAIL_RATIO" "$MAX_RETRY_PER_RUN" \
|
||||
"$DRY_RUN" "$VERBOSE" "$LOG_FILE" << 'PYEOF'
|
||||
import json, sys, os, subprocess, urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
tmpdir = sys.argv[1]
|
||||
api_base = sys.argv[2]
|
||||
token = sys.argv[3]
|
||||
state_dir = sys.argv[4]
|
||||
window_min = int(sys.argv[5])
|
||||
min_success = int(sys.argv[6])
|
||||
max_fail_pct = int(sys.argv[7])
|
||||
max_retry = int(sys.argv[8])
|
||||
dry_run = sys.argv[9] == "true"
|
||||
verbose = sys.argv[10] == "true"
|
||||
log_file = sys.argv[11]
|
||||
|
||||
def log(level, msg):
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
line = f"[{ts}] [{level}] {msg}"
|
||||
try:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(line + "\n")
|
||||
except:
|
||||
pass
|
||||
if level in ("ERROR", "WARN") or verbose or dry_run:
|
||||
print(line)
|
||||
|
||||
# 加载 runs
|
||||
with open(os.path.join(tmpdir, "runs.json")) as f:
|
||||
runs_data = json.load(f)
|
||||
if isinstance(runs_data, dict):
|
||||
runs_data = runs_data.get("workflow_runs", runs_data.get("runs", []))
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_min)
|
||||
|
||||
# 筛选最近 N 分钟内完成的失败 runs
|
||||
candidates = []
|
||||
for r in runs_data:
|
||||
completed_str = r.get("completed_at", "")
|
||||
if not completed_str or completed_str.startswith("1970"):
|
||||
continue
|
||||
try:
|
||||
completed = datetime.fromisoformat(completed_str.replace("Z", "+00:00"))
|
||||
if completed < cutoff:
|
||||
continue
|
||||
except:
|
||||
continue
|
||||
if r.get("conclusion") != "failure":
|
||||
continue
|
||||
candidates.append(r)
|
||||
|
||||
if not candidates:
|
||||
log("INFO", f"没有发现最近 {window_min} 分钟内失败的 runs")
|
||||
log("INFO", "========== 检测结束 ==========")
|
||||
sys.exit(0)
|
||||
|
||||
log("INFO", f"发现 {len(candidates)} 个失败的 runs 需要分析")
|
||||
|
||||
retry_count = 0
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
|
||||
for run in candidates:
|
||||
run_id = run["id"]
|
||||
run_number = run.get("run_number", run_id)
|
||||
branch = run.get("head_branch", "")
|
||||
event = run.get("event", "")
|
||||
|
||||
log("INFO", f"分析 Run #{run_number} (id={run_id}) branch={branch} event={event}")
|
||||
|
||||
# 检查是否已重试
|
||||
state_file = os.path.join(state_dir, f"{run_id}.retry")
|
||||
prev_attempts = 0
|
||||
if os.path.exists(state_file):
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
prev_attempts = int(f.read().strip())
|
||||
except:
|
||||
pass
|
||||
if prev_attempts >= max_retry:
|
||||
log("INFO", f" Run #{run_number} 已重试过 {prev_attempts} 次,跳过")
|
||||
continue
|
||||
|
||||
# 获取 jobs
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{api_base}/runs/{run_id}/jobs",
|
||||
headers=headers
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
jobs_data = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
log("ERROR", f" 获取 jobs 失败: {e}")
|
||||
continue
|
||||
|
||||
jobs = jobs_data.get("jobs", [])
|
||||
total = len(jobs)
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
transient_jobs = []
|
||||
code_fail_jobs = []
|
||||
|
||||
for j in jobs:
|
||||
conclusion = j.get("conclusion", "")
|
||||
if conclusion == "success":
|
||||
success_count += 1
|
||||
elif conclusion == "failure":
|
||||
fail_count += 1
|
||||
steps = j.get("steps", [])
|
||||
if steps:
|
||||
first = steps[0]
|
||||
fname = first.get("name", "").lower()
|
||||
fconc = first.get("conclusion", "")
|
||||
# 瞬态故障特征:第一个 step(checkout/setup)失败
|
||||
if fconc == "failure" and any(kw in fname for kw in ["checkout", "set up", "setup"]):
|
||||
transient_jobs.append({
|
||||
"name": j["name"],
|
||||
"runner": j.get("runner_name", "?"),
|
||||
})
|
||||
else:
|
||||
code_fail_jobs.append(j["name"])
|
||||
else:
|
||||
code_fail_jobs.append(j["name"])
|
||||
|
||||
skip_count = total - success_count - fail_count
|
||||
log("INFO", f" Jobs: total={total} success={success_count} fail={fail_count} skip={skip_count}")
|
||||
|
||||
if transient_jobs:
|
||||
log("INFO", f" 瞬态故障 jobs: {', '.join(j['name'] for j in transient_jobs)}")
|
||||
if code_fail_jobs:
|
||||
log("INFO", f" 代码失败 jobs: {', '.join(code_fail_jobs)}")
|
||||
|
||||
# 判定
|
||||
is_transient = False
|
||||
reason = ""
|
||||
if transient_jobs and not code_fail_jobs:
|
||||
if success_count >= min_success:
|
||||
is_transient = True
|
||||
reason = f"所有失败 job 在 checkout/setup 阶段失败,{success_count} 个 job 成功"
|
||||
else:
|
||||
reason = f"checkout 失败但成功 job 数不足 ({success_count} < {min_success})"
|
||||
elif transient_jobs and code_fail_jobs:
|
||||
if fail_count < total * max_fail_pct / 100 and success_count >= min_success:
|
||||
is_transient = True
|
||||
reason = f"{len(transient_jobs)} 瞬态 + {len(code_fail_jobs)} 代码,但成功 job 占多数"
|
||||
else:
|
||||
reason = f"混合失败: {len(transient_jobs)} 瞬态 + {len(code_fail_jobs)} 代码"
|
||||
elif code_fail_jobs:
|
||||
reason = f"纯代码失败: {', '.join(code_fail_jobs[:3])}"
|
||||
else:
|
||||
reason = "无失败 job"
|
||||
|
||||
log("INFO", f" 判定: {reason}")
|
||||
|
||||
if not is_transient:
|
||||
log("INFO", " → 非瞬态故障,跳过")
|
||||
continue
|
||||
|
||||
# 触发重试
|
||||
log("WARN", f" → 检测到瞬态故障!准备重试 Run #{run_number}")
|
||||
|
||||
if dry_run:
|
||||
log("INFO", " [DRY-RUN] 跳过实际重试")
|
||||
continue
|
||||
|
||||
# 调用 re-run API
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{api_base}/runs/{run_id}/rerun",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
data=b""
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
new_run_id = result.get("id", "?")
|
||||
new_status = result.get("status", "?")
|
||||
|
||||
# 记录重试状态
|
||||
with open(state_file, "w") as f:
|
||||
f.write(str(prev_attempts + 1))
|
||||
|
||||
log("INFO", f" ✅ Re-run 成功! 新 Run ID: {new_run_id}, 状态: {new_status}")
|
||||
retry_count += 1
|
||||
except Exception as e:
|
||||
log("ERROR", f" ❌ Re-run 失败: {e}")
|
||||
|
||||
log("INFO", f"========== 检测结束: 检查 {len(candidates)} 个失败 runs,重试 {retry_count} 个 ==========")
|
||||
PYEOF
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run CI Transient Fault Auto-Retry every 5 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -19,6 +19,18 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash
|
||||
# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行
|
||||
if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then
|
||||
SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "")
|
||||
if [ -n "$SOURCE_HASH" ]; then
|
||||
echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}"
|
||||
else
|
||||
echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust"
|
||||
fi
|
||||
fi
|
||||
|
||||
BUILDER_NAME="ci-builder-persist"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
|
||||
@@ -31,6 +31,18 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash
|
||||
# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行
|
||||
if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then
|
||||
SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "")
|
||||
if [ -n "$SOURCE_HASH" ]; then
|
||||
echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}"
|
||||
else
|
||||
echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 确保持久 builder 存在并使用(幂等)
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# - docker-container driver, host 网络
|
||||
# - 层缓存保存在 buildkit 容器及其 _state 命名卷中,job 结束不清理
|
||||
# - 宿主机 ci-docker-cleanup.sh 已豁免该 builder
|
||||
# - 每次执行自动同步宿主机 docker config 到 BuildKit 容器(确保 registry 认证)
|
||||
# 用法: bash scripts/ci/ensure_persistent_builder.sh
|
||||
set -eu
|
||||
|
||||
@@ -34,3 +35,26 @@ docker buildx use "$BUILDER"
|
||||
docker buildx inspect "$BUILDER" --bootstrap
|
||||
echo "✅ builder ready"
|
||||
docker buildx ls | head -5
|
||||
|
||||
# === 同步宿主机 docker config 到 BuildKit 容器(确保 registry 认证) ===
|
||||
# BuildKit 容器名遵循 docker buildx 命名规则: buildx_buildkit_<builder-name>_0
|
||||
BUILDKIT_CONTAINER="buildx_buildkit_${BUILDER}_0"
|
||||
|
||||
if docker inspect "$BUILDKIT_CONTAINER" >/dev/null 2>&1; then
|
||||
# 宿主机 docker config 路径
|
||||
HOST_DOCKER_CONFIG="/root/.docker/config.json"
|
||||
|
||||
if [ -f "$HOST_DOCKER_CONFIG" ]; then
|
||||
echo "=== 同步 docker config 到 BuildKit 容器 ==="
|
||||
# 确保容器内 .docker 目录存在
|
||||
docker exec "$BUILDKIT_CONTAINER" mkdir -p /root/.docker
|
||||
# 拷贝 config.json
|
||||
docker cp "$HOST_DOCKER_CONFIG" "$BUILDKIT_CONTAINER:/root/.docker/config.json"
|
||||
echo "✅ docker config 已同步到 BuildKit 容器"
|
||||
else
|
||||
echo "⚠️ 宿主机 docker config 不存在: $HOST_DOCKER_CONFIG(跳过同步)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ BuildKit 容器不存在: $BUILDKIT_CONTAINER(跳过 config 同步)"
|
||||
fi
|
||||
|
||||
|
||||
@@ -303,9 +303,10 @@ def main():
|
||||
"CI/CD Pipeline / CI Gate (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+Security,Type Check+Migration 合并为 Python
|
||||
"CI/CD Pipeline / Validate - Style (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Security (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
|
||||
@@ -197,8 +197,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 创建主数据库(xdist 模式下各 worker 会创建自己的数据库,主库作为 fallback)
|
||||
echo "创建主测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
@@ -304,8 +304,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
|
||||
# 清理所有以 CI_DB_NAME 开头的数据库(主库 + worker 库)
|
||||
PGPASSWORD="${SHARED_PG_PASSWORD}" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -344,4 +344,4 @@ python3 scripts/ci_coverage_summary.py
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "=== CI Integration Tests 全部通过 ✅ ==="
|
||||
echo "=== CI Integration Tests 全部通过 ✅ ==="
|
||||
+106
-47
@@ -1,77 +1,136 @@
|
||||
#!/bin/bash
|
||||
# CI Unit Tests Job 主脚本
|
||||
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
|
||||
# 包含:依赖缓存、增量测试选择、覆盖率测试、diff覆盖率门禁
|
||||
set -eu
|
||||
|
||||
JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
# --- 依赖缓存检查 ---
|
||||
# 如果 requirements 文件未变化且依赖已安装,跳过 pip install(持久 runner 优化)
|
||||
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash"
|
||||
CURRENT_REQ_HASH=""
|
||||
if [ -f requirements-base.txt ] && [ -f requirements.txt ] && [ -f requirements-dev.txt ]; then
|
||||
CURRENT_REQ_HASH=$(cat requirements-base.txt requirements.txt requirements-dev.txt | md5sum | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
SKIP_PIP_INSTALL=false
|
||||
if [ -n "$CURRENT_REQ_HASH" ] && [ -f "$REQ_HASH_FILE" ]; then
|
||||
CACHED_HASH=$(cat "$REQ_HASH_FILE")
|
||||
if [ "$CACHED_HASH" = "$CURRENT_REQ_HASH" ]; then
|
||||
# 验证关键包是否还在
|
||||
if python3 -c "import pytest; import celery" 2>/dev/null; then
|
||||
echo "✅ 依赖无变化 (hash=$CURRENT_REQ_HASH),跳过 pip install"
|
||||
SKIP_PIP_INSTALL=true
|
||||
else
|
||||
echo "⚠️ 依赖 hash 匹配但关键包缺失,重新安装"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 安装依赖 ---
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
# pip install 带重试(网络不稳定时自动重试)
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
if [ "$SKIP_PIP_INSTALL" = "false" ]; then
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
# pip install 带重试(网络不稳定时自动重试),合并为一次调用减少开销
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt -r requirements.txt -r requirements-dev.txt && break
|
||||
echo "pip install 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
# 保存 hash 标记
|
||||
if [ -n "$CURRENT_REQ_HASH" ]; then
|
||||
echo "$CURRENT_REQ_HASH" > "$REQ_HASH_FILE"
|
||||
fi
|
||||
fi
|
||||
pytest --version
|
||||
|
||||
# 双保险:确保numpy已安装
|
||||
echo "=== 验证 numpy 安装 ==="
|
||||
SKIP_NUMPY_TESTS=0
|
||||
python3 -m pip install numpy==1.26.4 || {
|
||||
echo "❌ numpy 首次安装失败,尝试不使用缓存重新安装..."
|
||||
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
|
||||
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
|
||||
SKIP_NUMPY_TESTS=1
|
||||
if python3 -c "import numpy; assert numpy.__version__ == '1.26.4'" 2>/dev/null; then
|
||||
echo "✅ numpy 1.26.4 已就绪(缓存命中)"
|
||||
else
|
||||
echo "需要安装 numpy 1.26.4..."
|
||||
python3 -m pip install numpy==1.26.4 || {
|
||||
echo "❌ numpy 首次安装失败,尝试不使用缓存重新安装..."
|
||||
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
|
||||
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
|
||||
SKIP_NUMPY_TESTS=1
|
||||
}
|
||||
}
|
||||
}
|
||||
fi
|
||||
if [ "$SKIP_NUMPY_TESTS" = "0" ]; then
|
||||
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 安装成功')" || {
|
||||
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 就绪')" || {
|
||||
echo "⚠️ numpy 导入失败,跳过需要 numpy 的测试"
|
||||
SKIP_NUMPY_TESTS=1
|
||||
}
|
||||
fi
|
||||
|
||||
# --- 增量测试选择(仅PR) ---
|
||||
# --- 增量测试选择(PR + push 均支持) ---
|
||||
UNIT_TEST_MODE="full"
|
||||
SELECTED_TEST_FILES="tests/unit"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
IS_PULL_REQUEST=false
|
||||
IS_PUSH=false
|
||||
[ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && IS_PULL_REQUEST=true
|
||||
[ "${GITHUB_EVENT_NAME:-}" = "push" ] && IS_PUSH=true
|
||||
|
||||
if ($IS_PULL_REQUEST || $IS_PUSH) && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo ""
|
||||
echo "=== 增量测试选择 ==="
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
|
||||
|
||||
CHANGED_FILES=""
|
||||
|
||||
if $IS_PULL_REQUEST; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
|
||||
elif $IS_PUSH && [ -n "${GITHUB_SHA:-}" ]; then
|
||||
# Push 事件:通过 GitHub API 获取本次 push 改动的文件
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}"
|
||||
RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3.diff" "$API_URL" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$RESPONSE" ]; then
|
||||
CHANGED_FILES=$(echo "$RESPONSE" | grep '^diff --git' | sed 's|diff --git a/\(.*\) b/.*|\1|' || echo "")
|
||||
fi
|
||||
|
||||
# 备用方案:获取 previous commit SHA 再查 API
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
PREV_SHA=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits?sha=${GITHUB_SHA}&per_page=2" \
|
||||
| python3 -c "import sys,json; commits=json.load(sys.stdin); print(commits[1]['sha'] if len(commits)>1 else '')" 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_SHA" ]; then
|
||||
COMPARE_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/compare/${PREV_SHA}...${GITHUB_SHA}"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$COMPARE_URL" \
|
||||
| python3 -c "import sys,json; data=json.load(sys.stdin); [print(f['filename']) for f in data.get('files',[]) if f['status'] != 'removed']" 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
|
||||
set +e
|
||||
CHANGED_FILES="$CHANGED_FILES" \
|
||||
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
|
||||
python3 scripts/ci/select_unit_tests.py
|
||||
SELECT_EXIT=$?
|
||||
set -e
|
||||
if [ $SELECT_EXIT -eq 0 ]; then
|
||||
UNIT_TEST_MODE="incremental"
|
||||
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
|
||||
SELECTED_TEST_FILES="$TEST_FILES"
|
||||
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
|
||||
|
||||
if [ -n "$CHANGED_FILES" ]; then
|
||||
set +e
|
||||
CHANGED_FILES="$CHANGED_FILES" \
|
||||
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
|
||||
python3 scripts/ci/select_unit_tests.py
|
||||
SELECT_EXIT=$?
|
||||
set -e
|
||||
if [ $SELECT_EXIT -eq 0 ]; then
|
||||
UNIT_TEST_MODE="incremental"
|
||||
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
|
||||
SELECTED_TEST_FILES="$TEST_FILES"
|
||||
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
|
||||
else
|
||||
echo "全量模式(增量选择失败)"
|
||||
fi
|
||||
else
|
||||
echo "全量模式"
|
||||
echo "无法获取改动文件列表,使用全量模式"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -116,7 +175,7 @@ if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ];
|
||||
|
||||
PR_CODE_DIR="/tmp/pr-code-$$"
|
||||
mkdir -p "$PR_CODE_DIR"
|
||||
# 备份PR代码(含coverage.xml,diff-cover需要用到
|
||||
# 备份PR代码(含coverage.xml,diff-cover需要用到)
|
||||
find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
|
||||
rm -rf .git
|
||||
git init > /dev/null 2>&1
|
||||
|
||||
@@ -409,8 +409,8 @@ except:
|
||||
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
@@ -431,8 +431,8 @@ conn.close()
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
|
||||
+26
-14
@@ -1,18 +1,39 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:Checkout 代码(带重试)
|
||||
# CI 公共步骤:Checkout 代码(流式下载+解压,带重试)
|
||||
# 用法:直接 source 或调用,需要 GITHUB_TOKEN 环境变量
|
||||
set -eu
|
||||
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
import io, os, sys, tarfile, time, urllib.request, urllib.error
|
||||
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
# 流式读取:先读少量数据确认连接成功,再大块读取
|
||||
first_chunk = response.read(8192)
|
||||
buf = io.BytesIO()
|
||||
buf.write(first_chunk)
|
||||
while True:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf.write(chunk)
|
||||
buf.seek(0)
|
||||
with tarfile.open(fileobj=buf, mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
@@ -31,14 +52,5 @@ for attempt in range(5):
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
# CI pipeline speedup batch 1
|
||||
|
||||
@@ -169,8 +169,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
@@ -185,8 +185,8 @@ conn.close()
|
||||
echo ""
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
|
||||
Regular → Executable
+38
-54
@@ -1,14 +1,20 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
# CI Validate: 安全扫描(validate-security)
|
||||
# 包含:密钥扫描、bandit 安全扫描(仅告警)、pip-audit 依赖漏洞(仅告警)、CI 脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
echo "=== CI Validate: 安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
echo "=== [1/4] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q --no-cache-dir detect-secrets || {
|
||||
echo "⚠️ detect-secrets install failed, retrying without cache..."
|
||||
python3 -m pip install -q --no-cache-dir --no-binary :all: detect-secrets || {
|
||||
echo "❌ detect-secrets install failed after retry"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
@@ -56,23 +62,9 @@ for fpath, items in data.get('results', {}).items():
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
# --- Bandit 安全扫描(仅告警)---
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
echo "=== [2/4] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
@@ -83,42 +75,34 @@ else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警)---
|
||||
echo ""
|
||||
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
echo "=== [3/4] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q --no-cache-dir pip-audit || {
|
||||
echo "⚠️ pip-audit install failed (cache issue?), retrying..."
|
||||
python3 -m pip install -q --no-cache-dir pip-audit || {
|
||||
echo "⚠️ pip-audit unavailable, skipping dependency vulnerability scan (advisory)"
|
||||
pip-audit --version 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
if command -v pip-audit >/dev/null 2>&1 || python3 -m pip show pip-audit >/dev/null 2>&1; then
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
else
|
||||
echo "⚠️ pip-audit not available, skipping dependency vulnerability scan (advisory)"
|
||||
fi
|
||||
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] CI & shell scripts syntax validation ==="
|
||||
echo "=== [4/4] CI & shell scripts syntax validation ==="
|
||||
SYNTAX_ERROR=0
|
||||
# 检查所有 CI shell 脚本
|
||||
for script in scripts/ci/*.sh; do
|
||||
@@ -154,4 +138,4 @@ fi
|
||||
echo "✅ All CI scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
echo "=== CI Validate: 安全扫描 全部通过 ✅ ==="
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码风格检查(validate-style)
|
||||
# 包含:Python 字节码编译、black 格式、isort 排序、ruff lint、vulture 死代码(仅告警)
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码风格检查 ==="
|
||||
|
||||
# --- Python 字节码编译 ---
|
||||
echo ""
|
||||
echo "=== [1/3] Python bytecode compilation ==="
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
echo "✅ Bytecode compilation passed"
|
||||
|
||||
# --- 代码格式检查(全量)---
|
||||
echo ""
|
||||
echo "=== [2/3] Code formatting (black + isort + ruff) ==="
|
||||
echo "Full scan mode"
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
echo "✅ Code formatting checks passed"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警)---
|
||||
echo ""
|
||||
echo "=== [3/3] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q --no-cache-dir vulture || echo "⚠️ vulture install failed, skipping dead code detection"
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码风格检查 全部通过 ✅ ==="
|
||||
@@ -397,7 +397,7 @@ fi
|
||||
echo "Stopping old containers..."
|
||||
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
|
||||
# Worker 需要更长时间(视频任务最长可能5分钟)
|
||||
docker stop -t 300 xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker stop -t 120 xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker stop -t 30 xiaoxia-api-staging 2>/dev/null || true
|
||||
docker stop -t 10 xiaoxia-web-staging 2>/dev/null || true
|
||||
docker rm xiaoxia-worker-staging xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
@@ -187,7 +187,7 @@ health_check() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 超时了
|
||||
|
||||
@@ -602,3 +602,148 @@ class TestFontsizeCompensation:
|
||||
result = build_ass_style("S", font_size=0)
|
||||
parts = result.split(",")
|
||||
assert int(parts[2]) >= 1
|
||||
|
||||
|
||||
# ── 标题自由位置拖拽(工单 #1405 方案 B)──────────────────────────────────────
|
||||
|
||||
|
||||
def _title_style_line(content: str) -> str:
|
||||
return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
|
||||
|
||||
def _title_dialogue_line(content: str) -> str:
|
||||
return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0]
|
||||
|
||||
|
||||
class TestTitleFreePosition:
|
||||
"""pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。"""
|
||||
|
||||
def _base_kwargs(self):
|
||||
return dict(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=8.0,
|
||||
title_text="测试标题",
|
||||
)
|
||||
|
||||
def test_valid_position_injects_pos_tag_and_alignment_5(self):
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300},
|
||||
)
|
||||
# Dialogue 文本前注入 {\pos(540,300)}
|
||||
dialogue = _title_dialogue_line(content)
|
||||
assert "{\\pos(540,300)}" in dialogue
|
||||
# TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "5"
|
||||
|
||||
def test_boundary_coordinates_zero_and_max_accepted(self):
|
||||
"""边界值 0 和 video_width/video_height 合法(闭区间)。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 0, "pos_y": 1920},
|
||||
)
|
||||
assert "{\\pos(0,1920)}" in _title_dialogue_line(content)
|
||||
|
||||
content2 = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 1080, "pos_y": 0},
|
||||
)
|
||||
assert "{\\pos(1080,0)}" in _title_dialogue_line(content2)
|
||||
|
||||
def test_no_coords_output_identical_to_before(self):
|
||||
"""不传坐标 → 输出与现有断言完全一致(回归保护)。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
# 无 \pos 注入
|
||||
assert "\\pos(" not in content
|
||||
# Alignment 走 position 映射(top → 8)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "8"
|
||||
|
||||
def test_out_of_bounds_falls_back(self):
|
||||
"""越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||||
)
|
||||
assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected"
|
||||
assert content == base, f"({pos_x},{pos_y}) output differs from fallback"
|
||||
|
||||
def test_invalid_coords_falls_back(self):
|
||||
"""非法类型坐标 → 回退原逻辑。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||||
)
|
||||
assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected"
|
||||
assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback"
|
||||
|
||||
def test_only_one_coord_falls_back(self):
|
||||
"""只传 pos_x 或 pos_y → 回退原逻辑。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36},
|
||||
)
|
||||
content_x = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36, "pos_x": 540},
|
||||
)
|
||||
content_y = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36, "pos_y": 300},
|
||||
)
|
||||
assert content_x == base
|
||||
assert content_y == base
|
||||
assert "\\pos(" not in content_x
|
||||
assert "\\pos(" not in content_y
|
||||
|
||||
def test_position_three_levels_unchanged_without_coords(self):
|
||||
"""无坐标时 top/center/bottom 三档 Alignment 输出不变。"""
|
||||
for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": position, "size": 36},
|
||||
)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == expected_align
|
||||
|
||||
def test_pos_overrides_position_alignment(self):
|
||||
"""有合法坐标时,无论 position 是什么,Alignment 都固定为 5。"""
|
||||
for position in ["top", "center", "bottom"]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200},
|
||||
)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "5"
|
||||
assert "{\\pos(100,200)}" in _title_dialogue_line(content)
|
||||
|
||||
def test_subtitle_not_affected_by_pos(self):
|
||||
"""pos_x/pos_y 只影响 Title,Subtitle 输出不变。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 540, "pos_y": 300},
|
||||
subtitle_text="配音字幕",
|
||||
subtitle_config={"position": "bottom", "size": 24},
|
||||
)
|
||||
sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||||
sub_fields = [f.strip() for f in sub_style.split(",")]
|
||||
assert sub_fields[18] == "2" # bottom
|
||||
sub_dialogue = [
|
||||
line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line
|
||||
][0]
|
||||
assert "\\pos(" not in sub_dialogue
|
||||
|
||||
@@ -197,14 +197,14 @@ class TestComputeAssetAvailability:
|
||||
|
||||
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
|
||||
# [0,2] 扩边到 [0,3.5],[9,10] 扩边到 [7.5,10];空闲 [3.5,7.5]=4.0s >=3
|
||||
# 使用 [0,2] 与 [9,10],扩边后空闲 [3.5,7.5]=4.0s ≥3 → usable
|
||||
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),
|
||||
_range(9.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -236,8 +236,8 @@ class TestComputeAssetAvailability:
|
||||
assert info["usable"] is True
|
||||
|
||||
def test_segment_edge_gap_constant(self):
|
||||
"""边缘间隙常量为 0.3s(与 MediaKit 冲突检测同口径)。"""
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
"""边缘间隙常量为 1.5s(与 MediaKit 冲突检测同口径)。"""
|
||||
assert SEGMENT_EDGE_GAP == 1.5
|
||||
|
||||
def test_domain_entity_metadata_dict_form(self):
|
||||
"""领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。
|
||||
@@ -386,24 +386,71 @@ class TestSmartMatchFiltersExhausted:
|
||||
_fresh_asset("a-fresh-1"),
|
||||
]
|
||||
resp = self._call(assets)
|
||||
returned_ids = {item.asset.id for item in resp.items}
|
||||
returned_ids = {item.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)
|
||||
assert all(item.usable for item in resp.items)
|
||||
|
||||
def test_all_exhausted_returns_empty(self):
|
||||
"""全部素材已用尽时返回空列表(不报错,前端显示空结果)。"""
|
||||
def test_all_exhausted_falls_back_to_all(self):
|
||||
"""全部素材已用尽时回退保留全部(不返回空——空结果会让前端回退全选,
|
||||
反而绕过评分排序;耗尽素材仍可走复用区间)。"""
|
||||
assets = [_exhausted_asset("a-ex-1"), _exhausted_asset("a-ex-2")]
|
||||
resp = self._call(assets)
|
||||
assert resp.items == []
|
||||
returned_ids = {item.id for item in resp.items}
|
||||
assert returned_ids == {"a-ex-1", "a-ex-2"}
|
||||
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)
|
||||
assert all(item.usable for item in resp.items)
|
||||
|
||||
|
||||
class TestSmartMatchFlatStructure:
|
||||
"""P0 回归:smart-match 响应必须扁平——item 顶层直接可读素材字段,
|
||||
前端 items.map(a => a.id) 不能再拿到 undefined(此前 item.asset 嵌套包装
|
||||
导致 GET /assets/undefined 404 + from-assets 422,自动模式全链路断裂)。"""
|
||||
|
||||
def test_item_id_at_top_level(self):
|
||||
"""item.id 直接在顶层可读,不存在 item.asset 包装层。"""
|
||||
assets = [_fresh_asset("a-flat-1"), _fresh_asset("a-flat-2")]
|
||||
resp = TestSmartMatchFiltersExhausted()._call(assets)
|
||||
ids = [item.id for item in resp.items]
|
||||
assert ids == ["a-flat-1", "a-flat-2"]
|
||||
# 嵌套 asset 字段已移除
|
||||
assert all(not hasattr(item, "asset") for item in resp.items)
|
||||
|
||||
def test_item_is_asset_response_superset(self):
|
||||
"""条目携带 AssetResponse 全部关键字段 + usable/余量,前端可直接渲染卡片。"""
|
||||
assets = [_fresh_asset("a-fields-1")]
|
||||
resp = TestSmartMatchFiltersExhausted()._call(assets)
|
||||
item = resp.items[0]
|
||||
assert item.id == "a-fields-1"
|
||||
assert item.name == "fresh-a-fields-1"
|
||||
assert item.storage_key == "key/test-asset.mp4"
|
||||
assert item.mime_type == "video/mp4"
|
||||
assert item.duration == 60.0
|
||||
assert item.status == "ready"
|
||||
assert item.thumbnail_url is None or item.thumbnail_url.startswith("http")
|
||||
# 余量/可用性字段顶层可读(isAssetUsable 依赖)
|
||||
assert item.usable is True
|
||||
assert item.used_duration == 0.0
|
||||
assert item.available_duration == 60.0
|
||||
assert item.used_ratio == 0.0
|
||||
# 评分字段保留
|
||||
assert 0.0 <= item.score <= 100.0
|
||||
assert isinstance(item.breakdown, dict)
|
||||
|
||||
def test_score_and_breakdown_preserved(self):
|
||||
"""扁平化后评分字段不丢失。"""
|
||||
assets = [_fresh_asset("a-score-1")]
|
||||
resp = TestSmartMatchFiltersExhausted()._call(assets)
|
||||
item = resp.items[0]
|
||||
assert isinstance(item.score, float)
|
||||
assert item.score > 0
|
||||
assert isinstance(item.breakdown, dict) and item.breakdown
|
||||
|
||||
@@ -124,9 +124,9 @@ def _ranges(db, aid="a1"):
|
||||
|
||||
|
||||
def test_config_constants():
|
||||
assert MAX_RANGE_USE_COUNT == 3
|
||||
assert REUSE_RATIO_LIMIT == 0.15
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
assert MAX_RANGE_USE_COUNT == 2
|
||||
assert REUSE_RATIO_LIMIT == 0.10
|
||||
assert SEGMENT_EDGE_GAP == 1.5
|
||||
|
||||
|
||||
# ── get_used_segments ─────────────────────────────────────────────────────────
|
||||
@@ -320,13 +320,13 @@ def test_find_reusable_prefers_oldest_unused(patched_model):
|
||||
|
||||
|
||||
def test_find_reusable_excludes_max_use_count(patched_model):
|
||||
"""use_count 达到上限(3)的区间不再参与复用;全部达上限返回 None。"""
|
||||
"""use_count 达到上限(2)的区间不再参与复用;全部达上限返回 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"},
|
||||
{"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-01-01T00:00:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
@@ -335,22 +335,22 @@ def test_find_reusable_excludes_max_use_count(patched_model):
|
||||
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 的不可复用。"""
|
||||
def test_find_reusable_third_use_rejected(patched_model):
|
||||
"""同区间复用第 3 次被拒绝:use_count=1 的可复用,use_count=2 的不可复用。"""
|
||||
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"},
|
||||
{"start": 0.0, "end": 10.0, "use_count": 1, "last_used_at": "2026-03-01T00:00:00"},
|
||||
{"start": 10.0, "end": 20.0, "use_count": 2, "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 的(虽然它更老)
|
||||
# 只能选 use_count=1 的区间(start=0),不能选 use_count=2 的(虽然它更老)
|
||||
assert result is not None and result[0] == 0.0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""测试 create_video_record_and_dedup 传递 user_id 到查重逻辑.
|
||||
|
||||
验证 P0 修复:查重范围从项目级扩大到用户级。
|
||||
dedup_helpers 必须把 user_id 传给 compute_duplicate_rate。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Mock cv2/numpy before imports
|
||||
sys.modules.setdefault("cv2", MagicMock())
|
||||
sys.modules.setdefault("numpy", MagicMock())
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
sys.path.insert(0, str(ROOT / "apps" / "worker"))
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
|
||||
class TestDedupHelpersUserIdPassthrough:
|
||||
"""验证 dedup_helpers 把 user_id 传递给 compute_duplicate_rate."""
|
||||
|
||||
def test_user_id_passed_to_compute_duplicate_rate(self):
|
||||
"""create_video_record_and_dedup 必须传 user_id 给 compute_duplicate_rate."""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
session = MagicMock()
|
||||
mock_video_repo = MagicMock()
|
||||
|
||||
mock_fingerprint = MagicMock()
|
||||
mock_fingerprint.to_dict.return_value = {"md5": "test", "keyframe_phashes": ["aa"]}
|
||||
|
||||
mock_deduplicator = MagicMock()
|
||||
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
|
||||
mock_deduplicator.check_duplicate.return_value = None
|
||||
mock_deduplicator.compute_duplicate_rate.return_value = 42.5
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=mock_video_repo,
|
||||
),
|
||||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-001",
|
||||
project_id="proj-001",
|
||||
user_id="user-abc",
|
||||
batch_id="",
|
||||
file_url="https://example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=15.0,
|
||||
video_path="/tmp/fake_video.mp4",
|
||||
mode="smart",
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 验证 compute_duplicate_rate 被调用且 user_id 正确传递
|
||||
mock_deduplicator.compute_duplicate_rate.assert_called_once()
|
||||
call_kwargs = mock_deduplicator.compute_duplicate_rate.call_args
|
||||
assert (
|
||||
call_kwargs.kwargs.get("user_id") == "user-abc"
|
||||
), f"user_id 应传递给 compute_duplicate_rate,实际: {call_kwargs}"
|
||||
|
||||
def test_empty_user_id_still_works(self):
|
||||
"""user_id 为空时仍然正常执行(回退到 project 级比较)."""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
session = MagicMock()
|
||||
mock_video_repo = MagicMock()
|
||||
|
||||
mock_fingerprint = MagicMock()
|
||||
mock_fingerprint.to_dict.return_value = {"md5": "test"}
|
||||
|
||||
mock_deduplicator = MagicMock()
|
||||
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
|
||||
mock_deduplicator.check_duplicate.return_value = None
|
||||
mock_deduplicator.compute_duplicate_rate.return_value = 0.0
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=mock_video_repo,
|
||||
),
|
||||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-002",
|
||||
project_id="proj-002",
|
||||
user_id="",
|
||||
batch_id="",
|
||||
file_url="https://example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="smart",
|
||||
session=session,
|
||||
)
|
||||
|
||||
mock_deduplicator.compute_duplicate_rate.assert_called_once()
|
||||
call_kwargs = mock_deduplicator.compute_duplicate_rate.call_args
|
||||
assert call_kwargs.kwargs.get("user_id") == ""
|
||||
|
||||
def test_duplicate_rate_saved_to_video_record(self):
|
||||
"""compute_duplicate_rate 的返回值应写入 generated_video.duplicate_rate."""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
session = MagicMock()
|
||||
mock_video_repo = MagicMock()
|
||||
|
||||
mock_fingerprint = MagicMock()
|
||||
mock_fingerprint.to_dict.return_value = {"md5": "test"}
|
||||
|
||||
mock_deduplicator = MagicMock()
|
||||
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
|
||||
mock_deduplicator.check_duplicate.return_value = None
|
||||
mock_deduplicator.compute_duplicate_rate.return_value = 78.5
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=mock_video_repo,
|
||||
),
|
||||
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-003",
|
||||
project_id="proj-003",
|
||||
user_id="user-xyz",
|
||||
batch_id="",
|
||||
file_url="https://example.com/v.mp4",
|
||||
file_size=2048,
|
||||
duration=20.0,
|
||||
video_path="/tmp/fake2.mp4",
|
||||
mode="smart",
|
||||
session=session,
|
||||
)
|
||||
|
||||
# 验证 update 被调用(包含 duplicate_rate 的记录)
|
||||
mock_video_repo.update.assert_called_once()
|
||||
updated_video = mock_video_repo.update.call_args[0][0]
|
||||
assert updated_video.duplicate_rate == 78.5
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Tests for duplicate_rate computation and API response."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Mock cv2 and numpy before any imports that need them
|
||||
sys.modules.setdefault("cv2", MagicMock())
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
sys.path.insert(0, str(ROOT / "apps" / "worker"))
|
||||
|
||||
|
||||
class TestComputeDuplicateRate:
|
||||
"""Test VideoDeduplicator.compute_duplicate_rate."""
|
||||
|
||||
def _make_fingerprint(self, md5="abc123", phashes=None):
|
||||
from video_processing.dedup import VideoFingerprint
|
||||
|
||||
return VideoFingerprint(
|
||||
md5=md5,
|
||||
keyframe_phashes=phashes or ["ff00ff00ff00ff00"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1920, 1080),
|
||||
)
|
||||
|
||||
def _make_existing_video(self, vid, fingerprint_dict):
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
return GeneratedVideo(
|
||||
id=vid,
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name=f"video-{vid}",
|
||||
file_url=f"https://example.com/{vid}.mp4",
|
||||
file_size=1000,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
video_fingerprint=fingerprint_dict,
|
||||
)
|
||||
|
||||
def test_no_existing_videos_returns_zero(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint()
|
||||
session = MagicMock()
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = []
|
||||
session.query.return_value = query_mock
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 0.0
|
||||
|
||||
def test_md5_match_returns_100(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="exact_match_md5")
|
||||
session = MagicMock()
|
||||
|
||||
existing = self._make_existing_video("existing1", {"md5": "exact_match_md5", "keyframe_phashes": ["aa"]})
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = existing.id
|
||||
mock_model.project_id = existing.project_id
|
||||
mock_model.video_fingerprint = existing.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
# 链式 filter: 第一次 scope filter,第二次 self-exclusion filter
|
||||
# 让 filter() 返回的对象仍然支持 order_by() 链
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock # filter → filter chainable
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
|
||||
session.query.return_value = query_mock
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 100.0
|
||||
|
||||
def test_phash_similarity_computed(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="different_md5", phashes=["ff00ff00ff00ff00"])
|
||||
session = MagicMock()
|
||||
|
||||
existing = self._make_existing_video(
|
||||
"existing1",
|
||||
{"md5": "other_md5", "keyframe_phashes": ["ff00ff00ff00ff03"]},
|
||||
)
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = existing.id
|
||||
mock_model.project_id = existing.project_id
|
||||
mock_model.video_fingerprint = existing.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
|
||||
session.query.return_value = query_mock
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
# hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875
|
||||
assert rate == pytest.approx(96.88, abs=0.1)
|
||||
|
||||
def test_excludes_self_video(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="same_md5")
|
||||
session = MagicMock()
|
||||
|
||||
self_video = self._make_existing_video("vid1", {"md5": "same_md5", "keyframe_phashes": ["aa"]})
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = self_video.id
|
||||
mock_model.project_id = self_video.project_id
|
||||
mock_model.video_fingerprint = self_video.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = self_video
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
|
||||
session.query.return_value = query_mock
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 0.0
|
||||
|
||||
def test_takes_max_similarity(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="new_md5", phashes=["ff00ff00ff00ff00"])
|
||||
session = MagicMock()
|
||||
|
||||
existing1 = self._make_existing_video("e1", {"md5": "md5_1", "keyframe_phashes": ["ff00ff00ff00ff0f"]})
|
||||
existing2 = self._make_existing_video("e2", {"md5": "md5_2", "keyframe_phashes": ["ff00ff00ff00ff01"]})
|
||||
mock_model1 = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model1.id = existing1.id
|
||||
mock_model1.project_id = existing1.project_id
|
||||
mock_model1.video_fingerprint = existing1.video_fingerprint
|
||||
mock_model1.generated_at = "2026-01-02"
|
||||
mock_model2 = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model2.id = existing2.id
|
||||
mock_model2.project_id = existing2.project_id
|
||||
mock_model2.video_fingerprint = existing2.video_fingerprint
|
||||
mock_model2.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.side_effect = [existing1, existing2]
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model1,
|
||||
mock_model2,
|
||||
]
|
||||
session.query.return_value = query_mock
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
# max similarity: e2 distance=1, (1-1/64)*100 = 98.4375
|
||||
assert rate == pytest.approx(98.44, abs=0.1)
|
||||
|
||||
def test_user_id_scope_cross_project(self):
|
||||
"""传 user_id 时应跨项目查询,而非仅当前项目."""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="cross_proj_md5")
|
||||
session = MagicMock()
|
||||
|
||||
# 模拟一个不同项目但同一用户的视频
|
||||
existing = self._make_existing_video(
|
||||
"existing_other_proj", {"md5": "cross_proj_md5", "keyframe_phashes": ["aa"]}
|
||||
)
|
||||
existing.project_id = "proj2" # 不同项目
|
||||
existing.user_id = "user1"
|
||||
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = existing.id
|
||||
mock_model.project_id = existing.project_id
|
||||
mock_model.user_id = existing.user_id
|
||||
mock_model.video_fingerprint = existing.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
|
||||
session.query.return_value = query_mock
|
||||
|
||||
rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
"proj1",
|
||||
"vid1",
|
||||
session,
|
||||
user_id="user1",
|
||||
)
|
||||
|
||||
# 应通过 user_id 过滤,且匹配到跨项目视频
|
||||
assert rate == 100.0
|
||||
|
||||
def test_user_id_empty_falls_back_to_project(self):
|
||||
"""user_id 为空时应回退到 project_id 过滤."""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint()
|
||||
session = MagicMock()
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.order_by.return_value.limit.return_value.all.return_value = []
|
||||
session.query.return_value = query_mock
|
||||
|
||||
rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
"proj1",
|
||||
"vid1",
|
||||
session,
|
||||
user_id="",
|
||||
)
|
||||
|
||||
assert rate == 0.0
|
||||
# 验证使用的是 project_id 过滤(回退路径)
|
||||
# 通过检查 filter 被调用时的参数来间接验证
|
||||
|
||||
|
||||
class TestDuplicateRateAPI:
|
||||
"""Test that duplicate_rate is returned in API responses."""
|
||||
|
||||
def test_video_item_response_has_duplicate_rate(self):
|
||||
from app.schemas.video_center import VideoItemResponse
|
||||
|
||||
resp = VideoItemResponse(
|
||||
id="v1",
|
||||
project_id="p1",
|
||||
generation_task_id="t1",
|
||||
name="test.mp4",
|
||||
file_url="https://example.com/test.mp4",
|
||||
file_size=1000,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
duplicate_rate=75.5,
|
||||
)
|
||||
assert resp.duplicate_rate == 75.5
|
||||
|
||||
def test_video_item_response_duplicate_rate_default_none(self):
|
||||
from app.schemas.video_center import VideoItemResponse
|
||||
|
||||
resp = VideoItemResponse(
|
||||
id="v1",
|
||||
project_id="p1",
|
||||
generation_task_id="t1",
|
||||
name="test.mp4",
|
||||
file_url="https://example.com/test.mp4",
|
||||
file_size=1000,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
)
|
||||
assert resp.duplicate_rate is None
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for duration compensation when source video is shorter than configured duration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.worker.video_processing.unified_render_service import ResolvedClip, UnifiedRenderService
|
||||
|
||||
|
||||
class TestClipEffectiveDurationWithSpeedCompensation:
|
||||
"""Test _clip_effective_duration handles speed < 1 correctly."""
|
||||
|
||||
def test_normal_speed_returns_min(self):
|
||||
"""When speed=1.0, effective_duration = min(duration, actual_duration)."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/fake.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0, # shorter than configured
|
||||
playback_speed=1.0,
|
||||
)
|
||||
# Without speed compensation, effective = min(4, 3) = 3
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 3.0
|
||||
|
||||
def test_compensated_speed_returns_configured_duration(self):
|
||||
"""When speed < 1 (compensated), effective_duration = configured duration."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/fake.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0, # shorter than configured
|
||||
playback_speed=0.75, # compensated: 3/4 = 0.75
|
||||
)
|
||||
# With speed compensation, effective = configured duration = 4.0
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
|
||||
|
||||
def test_zero_actual_duration_returns_configured(self):
|
||||
"""When actual_duration=0, effective_duration = configured duration."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/fake.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=0.0,
|
||||
playback_speed=1.0,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
|
||||
|
||||
def test_compensated_speed_with_actual_zero(self):
|
||||
"""When speed < 1 and actual=0, still returns configured duration."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/fake.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=0.0,
|
||||
playback_speed=0.5,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
|
||||
|
||||
|
||||
class TestClipAdjustedDurationWithSpeedCompensation:
|
||||
"""Test _clip_adjusted_duration accounts for compensated speed."""
|
||||
|
||||
def test_adjusted_duration_with_compensation(self):
|
||||
"""Adjusted duration = min(duration, actual) / speed.
|
||||
With compensation: min(4,3)/0.75 = 3/0.75 = 4.0
|
||||
This equals the configured duration, which is the goal.
|
||||
"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/fake.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=4.0,
|
||||
actual_duration=3.0,
|
||||
playback_speed=0.75, # compensated
|
||||
)
|
||||
adjusted = UnifiedRenderService._clip_adjusted_duration(clip)
|
||||
# min(4,3)/0.75 = 3/0.75 = 4.0 (matches configured duration)
|
||||
assert abs(adjusted - 4.0) < 0.01
|
||||
@@ -38,6 +38,28 @@ def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
||||
return [(i, dur_min, dur_max) for i in range(count)]
|
||||
|
||||
|
||||
def _patch_zero_noise():
|
||||
"""消除 clips.py 排序随机噪声,用于确定性断言(如均衡分配)。
|
||||
|
||||
排序噪声(random.uniform(0, SCORE_RANDOM_NOISE_MAX))返回 0;
|
||||
其他 uniform 调用(片段时长随机)委托给独立 Random 实例,行为不变。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _patch_segments(segments=None):
|
||||
return patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
@@ -58,6 +80,10 @@ def _make_mock_asset(asset_id, duration):
|
||||
asset = MagicMock()
|
||||
asset.id = asset_id
|
||||
asset.duration = duration
|
||||
# score_asset 所需的属性(避免 MagicMock 导致类型比较错误)
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {}
|
||||
return asset
|
||||
|
||||
|
||||
@@ -126,7 +152,8 @@ class TestEditorClipsBySegments:
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||||
|
||||
with _patch_segments(DEFAULT_SEGMENTS):
|
||||
# 均衡分配由 use_count 贪心保证,消除排序噪声后确定性断言
|
||||
with _patch_zero_noise(), _patch_segments(DEFAULT_SEGMENTS):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
@@ -142,11 +169,12 @@ class TestEditorClipsBySegments:
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 4
|
||||
|
||||
# 验证轮询分配:a1, a2, a1, a2
|
||||
assert clips_data[0]["asset_id"] == "a1"
|
||||
assert clips_data[1]["asset_id"] == "a2"
|
||||
assert clips_data[2]["asset_id"] == "a1"
|
||||
assert clips_data[3]["asset_id"] == "a2"
|
||||
# 验证均衡分配(贪心策略保证):2个素材分4个片段,每个素材恰好使用2次
|
||||
from collections import Counter
|
||||
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
counts = Counter(asset_ids)
|
||||
assert counts["a1"] == 2 and counts["a2"] == 2
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_orders_start_at_zero(self, mock_storage):
|
||||
@@ -413,8 +441,13 @@ class TestEditorClipsDurationAndStartTime:
|
||||
|
||||
assert mock_calc.call_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["start_time"] == 12.5
|
||||
assert clips_data[1]["start_time"] == 18.0
|
||||
# clips_data 按 order 排序,但分配顺序因 shuffle 而随机,
|
||||
# 因此只验证两个 start_time 值都存在
|
||||
start_times = {c["start_time"] for c in clips_data}
|
||||
assert start_times == {12.5, 18.0}
|
||||
# 验证 order 仍然有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_asset_durations_deduped(self, mock_storage):
|
||||
@@ -631,7 +664,8 @@ class TestReuseRatioGate:
|
||||
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
|
||||
# 转场补偿: raw_duration = 5.0 + (13-1)*0.5/13 ≈ 5.462 → round(5.462,1) = 5.5
|
||||
assert abs(reused.get("a1", 0.0) - 5.5) < 0.1
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
|
||||
@@ -747,3 +781,84 @@ class TestReuseRatioGate:
|
||||
assert len(clips_data) == 2
|
||||
# 耗尽素材被跳过,两个片段都分配给新鲜素材
|
||||
assert all(c["asset_id"] == "fresh" for c in clips_data)
|
||||
|
||||
|
||||
# ── P0 回归:from-assets 对 undefined/null/空串 asset_ids 容错 ──────────────
|
||||
# 线上事故:前端 smart-match 拿到 {asset: {...}} 包装层后 items.map(a=>a.id)
|
||||
# 全为 undefined,POST /clips/from-assets 携带 null → 422,自动模式预览断裂。
|
||||
|
||||
|
||||
class TestClipsFromAssetsInvalidIds:
|
||||
def test_schema_filters_null_and_empty_ids(self):
|
||||
"""请求 schema 在 pre 阶段剔除 null/空串/空白 id,不触发 422。"""
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", " ", "a2"]) # type: ignore[list-item]
|
||||
assert body.asset_ids == ["a1", "a2"]
|
||||
|
||||
def test_schema_all_invalid_raises(self):
|
||||
"""全部为非法 id 时 min_length=1 兜底报校验错误(前端得到 422 而非脏数据)。"""
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ClipsFromAssetsRequest(asset_ids=[None, "", " "]) # type: ignore[list-item]
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_route_filters_invalid_ids_and_uses_valid(self, mock_storage):
|
||||
"""路由层二次兜底:混有 null/空串时只用合法 id 正常创建片段。"""
|
||||
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: _make_mock_asset(aid, 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"]) # type: ignore[list-item]
|
||||
|
||||
# 消除排序噪声,确定性断言两条合法素材各被使用
|
||||
with _patch_zero_noise(), _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
|
||||
result = 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 result.created_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert {c["asset_id"] for c in clips_data} == {"a1", "a2"}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_route_all_empty_ids_raises_400(self, mock_storage):
|
||||
"""schema 被绕过直接调路由、且 id 全非法时,路由 400 而非 500/422。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
body = MagicMock()
|
||||
body.asset_ids = [None, "", " "]
|
||||
|
||||
with _patch_segments(DEFAULT_SEGMENTS):
|
||||
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
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""_get_template_segments 回退路径测试.
|
||||
|
||||
验证三级回退链:
|
||||
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
|
||||
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
|
||||
3. 直接查表也失败 → 回退旧模板系统(template_segments)
|
||||
4. 全部失败 → 返回空列表
|
||||
|
||||
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
|
||||
from-assets 流程不再 400。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
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 _get_template_segments
|
||||
|
||||
TEST_TEMPLATE_ID = "tmpl-orphan-001"
|
||||
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
|
||||
|
||||
|
||||
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
|
||||
"""构造 mock TemplateClipConfig 领域实体."""
|
||||
cc = MagicMock()
|
||||
cc.order = order
|
||||
cc.min_duration = min_dur
|
||||
cc.max_duration = max_dur
|
||||
return cc
|
||||
|
||||
|
||||
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
|
||||
"""构造 mock 旧 TemplateSegment."""
|
||||
s = MagicMock()
|
||||
s.segment_order = segment_order
|
||||
s.duration_min = dur_min
|
||||
s.duration_max = dur_max
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTemplateSegmentsFallback:
|
||||
"""_get_template_segments 三级回退链."""
|
||||
|
||||
def test_new_system_works(self):
|
||||
"""路径1:新模板系统正常返回 → 直接使用."""
|
||||
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 2.0, 6.0)
|
||||
assert result[1] == (1, 3.0, 9.0)
|
||||
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_succeeds(self):
|
||||
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
|
||||
|
||||
模拟自建模板在 edit_templates 主表已删除/不存在,
|
||||
但 template_clip_configs 表有记录。
|
||||
"""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
|
||||
db = MagicMock()
|
||||
|
||||
# Mock SQLAlchemyTemplateClipConfigRepository
|
||||
direct_configs = [
|
||||
_make_clip_config(0, 2.0, 5.0),
|
||||
_make_clip_config(1, 3.0, 7.0),
|
||||
_make_clip_config(2, 4.0, 8.0),
|
||||
]
|
||||
with (
|
||||
__import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
assert result[1] == (1, 3.0, 7.0)
|
||||
assert result[2] == (2, 4.0, 8.0)
|
||||
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_empty_falls_to_old(self):
|
||||
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
old_segments = [_make_old_segment(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [] # 新表也没记录
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = old_segments
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_all_fail_returns_empty(self):
|
||||
"""路径4:三级全部失败 → 返回空列表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.side_effect = Exception("DB error")
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = [] # 旧表也空
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_direct_query_sorts_by_order(self):
|
||||
"""直接查表返回的结果按 order 排序."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
# 故意乱序
|
||||
configs = [
|
||||
_make_clip_config(2, 5.0, 10.0),
|
||||
_make_clip_config(0, 2.0, 4.0),
|
||||
_make_clip_config(1, 3.0, 6.0),
|
||||
]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert [r[0] for r in result] == [0, 1, 2]
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
assert result[1] == (1, 3.0, 6.0)
|
||||
assert result[2] == (2, 5.0, 10.0)
|
||||
|
||||
def test_direct_query_handles_none_durations(self):
|
||||
"""直接查表时 min/max_duration 为 None → 使用默认值."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
cc = MagicMock()
|
||||
cc.order = 0
|
||||
cc.min_duration = None
|
||||
cc.max_duration = None
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [cc]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
# None → default (5.0), max(None or None) → default (5.0)
|
||||
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
|
||||
|
||||
def test_new_system_returns_empty_tries_direct(self):
|
||||
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
|
||||
db = MagicMock()
|
||||
|
||||
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
|
||||
# 直接查表有数据 → 返回
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_existing_template_unaffected(self):
|
||||
"""正常模板(主表存在)行为不变."""
|
||||
configs = [_make_clip_config(0, 2.0, 5.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
# 直接查表不应被调用(新系统已返回)
|
||||
mock_repo_cls.assert_not_called()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for get_asset_recent_use_counts and smart-match high-use exclusion."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
|
||||
|
||||
class TestGetAssetRecentUseCounts:
|
||||
"""Test asset_segment_tracker.get_asset_recent_use_counts."""
|
||||
|
||||
def _make_asset_model(self, asset_id, used_time_ranges=None):
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
model = AssetModel(
|
||||
id=asset_id,
|
||||
name=f"asset-{asset_id}",
|
||||
file_type="video",
|
||||
status="ready",
|
||||
asset_library_id="lib1",
|
||||
project_id="proj1",
|
||||
file_size=1000,
|
||||
file_url=f"https://example.com/{asset_id}.mp4",
|
||||
uploaded_by_user_id="user1",
|
||||
)
|
||||
model.classification_result = json.dumps(
|
||||
{
|
||||
"used_time_ranges": used_time_ranges or [],
|
||||
}
|
||||
)
|
||||
return model
|
||||
|
||||
def test_empty_asset_ids_returns_empty(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
result = get_asset_recent_use_counts(db, [])
|
||||
assert result == {}
|
||||
|
||||
def test_no_usage_returns_zero(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
model = self._make_asset_model("a1", [])
|
||||
db.query.return_value.filter.return_value.all.return_value = [model]
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["a1"])
|
||||
assert result == {"a1": 0}
|
||||
|
||||
def test_counts_distinct_plan_ids(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
ranges = [
|
||||
{"start": 0, "end": 5, "plan_id": "plan1", "created_at": "2026-08-01T00:00:00"},
|
||||
{"start": 5, "end": 10, "plan_id": "plan1", "created_at": "2026-08-01T00:01:00"},
|
||||
{"start": 0, "end": 5, "plan_id": "plan2", "created_at": "2026-08-02T00:00:00"},
|
||||
{"start": 0, "end": 5, "plan_id": "plan3", "created_at": "2026-08-03T00:00:00"},
|
||||
]
|
||||
model = self._make_asset_model("a1", ranges)
|
||||
db.query.return_value.filter.return_value.all.return_value = [model]
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["a1"])
|
||||
assert result == {"a1": 3}
|
||||
|
||||
def test_limits_to_recent_n(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
ranges = [
|
||||
{"start": 0, "end": 5, "plan_id": f"plan{i}", "created_at": f"2026-08-{i+1:02d}T00:00:00"}
|
||||
for i in range(10)
|
||||
]
|
||||
model = self._make_asset_model("a1", ranges)
|
||||
db.query.return_value.filter.return_value.all.return_value = [model]
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["a1"], recent_video_count=5)
|
||||
assert result["a1"] == 5
|
||||
|
||||
def test_missing_asset_defaults_to_zero(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["missing_asset"])
|
||||
assert result == {"missing_asset": 0}
|
||||
@@ -32,16 +32,16 @@ class TestRecommendedTimeConflicts:
|
||||
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 扩边内 → 冲突。"""
|
||||
"""首尾紧贴(推荐 15 开始,已用 [10,15]):1.5s 扩边内 → 冲突。"""
|
||||
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 边缘间隙)→ 冲突。"""
|
||||
"""间隔 0.2s(< 1.5s 边缘间隙)→ 冲突。"""
|
||||
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
|
||||
"""间隔 2.0s(> 1.5s 边缘间隙)→ 不冲突。"""
|
||||
assert _recommended_time_conflicts(17.0, 5.0, [(10.0, 15.0)]) is False
|
||||
|
||||
def test_far_apart_no_conflict(self):
|
||||
"""相隔很远 → 不冲突。"""
|
||||
@@ -54,7 +54,9 @@ class TestRecommendedTimeConflicts:
|
||||
"""多个已用区间,任一冲突即返回 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
|
||||
assert (
|
||||
_recommended_time_conflicts(6.5, 2.0, used) is False
|
||||
) # range [6.5,8.5], just outside all expanded used ranges
|
||||
|
||||
def test_custom_edge_gap(self):
|
||||
"""edge_gap 可配置:gap=0 时紧贴不冲突(端点相接不算重叠)。"""
|
||||
@@ -64,5 +66,5 @@ class TestRecommendedTimeConflicts:
|
||||
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
|
||||
"""默认边缘间隙常量为 1.5s(配置常量)。"""
|
||||
assert SEGMENT_EDGE_GAP == 1.5
|
||||
|
||||
@@ -69,13 +69,13 @@ class TestRecommendedTimeConflicts:
|
||||
def test_conflict_exact_boundary_no_overlap(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 新语义:默认 0.3s 边缘间隙扩边,推荐 [10, 15] 与已用 [0, 10] 首尾相接
|
||||
# 新语义:默认 1.5s 边缘间隙扩边,推荐 [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
|
||||
# 间隙大于边缘间隙(2.0 > 1.5)→ 不冲突
|
||||
assert _recommended_time_conflicts(12.0, 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
|
||||
@@ -83,10 +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] 与 [10, 15] 首尾相接:0.3s 扩边内 → 冲突
|
||||
# 推荐 [15, 20] 与 [10, 15] 首尾相接:1.5s 扩边内 → 冲突
|
||||
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
|
||||
# 空闲段 (6.5, 8.5) 长 2.0s:推荐 [6.6, 8.4](dur=1.8)与三区间扩边均不接触
|
||||
assert _recommended_time_conflicts(6.6, 1.8, used) is False
|
||||
|
||||
|
||||
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
|
||||
@@ -306,6 +306,27 @@ class TestGetTemplateSegments:
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _patch_zero_noise():
|
||||
"""消除 clips.py 排序随机噪声(SCORE_RANDOM_NOISE_MAX 噪声返回 0)。
|
||||
|
||||
用于均衡分配等确定性断言;其他 uniform 调用(片段时长随机)行为不变。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
@@ -347,6 +368,10 @@ def _make_rich_asset(asset_id, duration, storage_key="v.mp4", mime="video/mp4"):
|
||||
asset.duration = duration
|
||||
asset.storage_key = storage_key
|
||||
asset.mime_type = mime
|
||||
# score_asset 所需的属性(避免 MagicMock 导致类型比较错误)
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {}
|
||||
return asset
|
||||
|
||||
|
||||
@@ -442,11 +467,15 @@ class TestFromAssetsByTemplateSegments:
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert 3.0 <= clips_data[0]["duration"] <= 5.0
|
||||
assert 4.0 <= clips_data[1]["duration"] <= 8.0
|
||||
# PR #1614 转场补偿:2 个片段时每 clip 时长 +(2-1)*0.5/2=0.25s
|
||||
# (xfade 重叠在渲染时扣除,故 clip 时长 = segment 随机时长 + 补偿),
|
||||
# 断言上界需计入补偿与一位小数舍入余量
|
||||
comp = (2 - 1) * 0.5 / 2
|
||||
assert 3.0 + comp - 0.1 <= clips_data[0]["duration"] <= 5.0 + comp + 0.1
|
||||
assert 4.0 + comp - 0.1 <= clips_data[1]["duration"] <= 8.0 + comp + 0.1
|
||||
|
||||
def test_assets_round_robin_assignment(self):
|
||||
"""素材按片段顺序轮询分配。"""
|
||||
def test_assets_balanced_assignment(self):
|
||||
"""素材按使用次数贪心分配(使用少的优先),保证均衡使用。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
@@ -462,20 +491,26 @@ class TestFromAssetsByTemplateSegments:
|
||||
mock_asset_repo.get.side_effect = get_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
# 消除排序噪声,确定性断言贪心均衡分配
|
||||
with _patch_zero_noise():
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
assert asset_ids == ["a1", "a2", "a1", "a2"]
|
||||
# 贪心分配保证均衡:2个素材分4个片段,每个素材恰好使用2次
|
||||
from collections import Counter
|
||||
|
||||
counts = Counter(asset_ids)
|
||||
assert counts["a1"] == 2 and counts["a2"] == 2
|
||||
|
||||
def test_orders_start_from_zero(self):
|
||||
"""片段 order 从 0 开始递增。"""
|
||||
|
||||
@@ -908,6 +908,10 @@ class TestAssetDurationsAlwaysFetched:
|
||||
def fake_get(asset_id):
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.duration = 30.0 # 每个素材 30 秒
|
||||
# score_asset 所需的属性
|
||||
mock_asset.quality_score = None
|
||||
mock_asset.created_at = None
|
||||
mock_asset.metadata = {}
|
||||
return mock_asset
|
||||
|
||||
asset_repo.get = MagicMock(side_effect=fake_get)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests for scene-change smart frame selection + random shuffle of segment processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.api.app.api.routes.templates_editor.clips import (
|
||||
_build_scene_segments,
|
||||
_pick_start_in_scene_segment,
|
||||
)
|
||||
from packages.shared.mediakit_client import MediaKitClient
|
||||
|
||||
# ── Part 1: Random shuffle tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRandomShuffle:
|
||||
"""验证 segments 处理顺序随机打乱逻辑."""
|
||||
|
||||
def test_same_segments_produce_different_asset_orders(self):
|
||||
"""同一批 segments 多次处理,asset 分配顺序有变化.
|
||||
|
||||
模拟打乱后的处理顺序,验证多次运行中 asset_id 分配顺序
|
||||
存在差异(概率性验证,运行 50 次应该至少出现 2 种排列)。
|
||||
"""
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||||
asset_ids = ["A", "B", "C", "D"]
|
||||
|
||||
observed_orders: list[tuple] = set()
|
||||
|
||||
for _ in range(50):
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
order_tuple = tuple(shuffled_indices)
|
||||
observed_orders.add(order_tuple)
|
||||
|
||||
# 50 次打乱,4! = 24 种排列,应出现多种不同排列
|
||||
assert len(observed_orders) > 1, "打乱应该产生多种不同顺序"
|
||||
|
||||
def test_clips_data_order_always_sorted(self):
|
||||
"""clips_data 按 order 排序后始终有序.
|
||||
|
||||
模拟打乱处理后 clips_data 按 order 排序,验证最终 order 为 [0,1,2,3]。
|
||||
"""
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||||
|
||||
for _ in range(20):
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
|
||||
# 模拟构建 clips_data(用 _seg_order 作为 order)
|
||||
clips_data = []
|
||||
for idx in shuffled_indices:
|
||||
seg_order, _, _ = segments[idx]
|
||||
clips_data.append({"order": seg_order, "asset_id": f"asset_{idx}"})
|
||||
|
||||
# 按 order 排序
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 验证 order 始终有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2, 3], f"排序后 order 应为 [0,1,2,3],实际为 {orders}"
|
||||
|
||||
|
||||
# ── Part 2: detect_scene_changes tests ────────────────────────────────────
|
||||
|
||||
|
||||
class TestDetectSceneChanges:
|
||||
"""验证 MediaKitClient.detect_scene_changes 方法."""
|
||||
|
||||
def _make_client(self) -> MediaKitClient:
|
||||
"""创建一个可用的 MediaKitClient(mock 配置)."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "http://test"
|
||||
mock_settings.return_value.mediakit_timeout = 30
|
||||
client = MediaKitClient()
|
||||
return client
|
||||
|
||||
def test_scene_change_success(self):
|
||||
"""SceneChange 策略成功返回时间戳列表."""
|
||||
client = self._make_client()
|
||||
|
||||
mock_frames = [
|
||||
{"image_url": "url1", "timestamp": 0.0},
|
||||
{"image_url": "url2", "timestamp": 3.2},
|
||||
{"image_url": "url3", "timestamp": 7.8},
|
||||
{"image_url": "url4", "timestamp": 12.5},
|
||||
]
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0 # 始终以 0.0 开头
|
||||
assert 3.2 in result
|
||||
assert 7.8 in result
|
||||
assert 12.5 in result
|
||||
assert result == sorted(result) # 应已排序
|
||||
|
||||
def test_scene_change_fallback_to_time_interval(self):
|
||||
"""SceneChange 失败降级到 TimeInterval 策略."""
|
||||
client = self._make_client()
|
||||
|
||||
# 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果
|
||||
fallback_frames = [
|
||||
{"image_url": "url1", "timestamp": 0.0},
|
||||
{"image_url": "url2", "timestamp": 5.0},
|
||||
{"image_url": "url3", "timestamp": 10.0},
|
||||
]
|
||||
|
||||
call_count = 0
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# 第一次 SceneChange 失败
|
||||
return None
|
||||
else:
|
||||
# 第二次 TimeInterval 成功
|
||||
assert kwargs.get("strategy") == "TimeInterval"
|
||||
return fallback_frames
|
||||
|
||||
with patch.object(client, "extract_frames", side_effect=side_effect):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0
|
||||
assert 5.0 in result
|
||||
assert 10.0 in result
|
||||
|
||||
def test_mediakit_not_available_returns_none(self):
|
||||
"""MediaKit 不可用时返回 None."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "" # 未配置
|
||||
mock_settings.return_value.mediakit_base_url = "http://test"
|
||||
mock_settings.return_value.mediakit_timeout = 30
|
||||
client = MediaKitClient()
|
||||
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
assert result is None
|
||||
|
||||
def test_both_strategies_fail_returns_none(self):
|
||||
"""SceneChange 和 TimeInterval 都失败时返回 None."""
|
||||
client = self._make_client()
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=None):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_prepends_zero_if_not_present(self):
|
||||
"""若帧列表中不包含 0.0,自动在开头添加."""
|
||||
client = self._make_client()
|
||||
|
||||
# 帧列表中没有 timestamp=0.0
|
||||
mock_frames = [
|
||||
{"image_url": "url1", "timestamp": 2.0},
|
||||
{"image_url": "url2", "timestamp": 5.5},
|
||||
]
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0
|
||||
assert 2.0 in result
|
||||
assert 5.5 in result
|
||||
|
||||
|
||||
# ── Part 2.2: Scene segment building and assignment ───────────────────────
|
||||
|
||||
|
||||
class TestSceneSegments:
|
||||
"""验证镜头段构建和分配逻辑."""
|
||||
|
||||
def test_build_scene_segments(self):
|
||||
"""从场景切换点正确构建镜头段."""
|
||||
scene_changes = [0.0, 3.2, 7.8, 12.5]
|
||||
asset_duration = 15.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
|
||||
assert len(segments) == 4
|
||||
assert segments[0] == (0.0, 3.2)
|
||||
assert segments[1] == (3.2, 7.8)
|
||||
assert segments[2] == (7.8, 12.5)
|
||||
assert segments[3] == (12.5, 15.0)
|
||||
|
||||
def test_build_scene_segments_filters_short(self):
|
||||
"""过滤掉过短的镜头段(< 0.5秒)."""
|
||||
scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0]
|
||||
asset_duration = 12.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
|
||||
# (0.0, 0.1) 长度 0.1 < 0.5 → 过滤
|
||||
# (0.1, 5.0) → 保留
|
||||
# (5.0, 5.3) 长度 0.3 < 0.5 → 过滤
|
||||
# (5.3, 10.0) → 保留
|
||||
# (10.0, 12.0) → 保留
|
||||
assert len(segments) == 3
|
||||
assert segments[0] == (0.1, 5.0)
|
||||
assert segments[1] == (5.3, 10.0)
|
||||
assert segments[2] == (10.0, 12.0)
|
||||
|
||||
def test_pick_start_in_segment(self):
|
||||
"""在镜头段内随机选取起始时间."""
|
||||
seg_start = 3.0
|
||||
seg_end = 8.0
|
||||
clip_duration = 2.0
|
||||
|
||||
starts = set()
|
||||
for _ in range(100):
|
||||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
assert start is not None
|
||||
assert seg_start <= start <= seg_end - clip_duration
|
||||
starts.add(round(start, 2))
|
||||
|
||||
# 应该有多个不同的起始时间
|
||||
assert len(starts) > 1
|
||||
|
||||
def test_pick_start_segment_too_short(self):
|
||||
"""镜头段太短无法容纳片段时返回 None."""
|
||||
result = _pick_start_in_scene_segment(0.0, 1.0, 2.0)
|
||||
assert result is None
|
||||
|
||||
def test_different_clips_from_different_scenes(self):
|
||||
"""不同片段应来自不同的镜头段(模拟分配逻辑)."""
|
||||
scene_changes = [0.0, 5.0, 10.0, 15.0]
|
||||
asset_duration = 18.0
|
||||
clip_duration = 3.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18)
|
||||
|
||||
# 模拟 3 个片段从不同镜头段取点
|
||||
scene_pool = list(segments)
|
||||
assigned_starts = []
|
||||
|
||||
for _ in range(3):
|
||||
if not scene_pool:
|
||||
break
|
||||
seg_start, seg_end = scene_pool.pop(0)
|
||||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
assert start is not None
|
||||
assigned_starts.append(start)
|
||||
|
||||
# 3 个片段分别从 3 个不同镜头段中选取
|
||||
assert len(assigned_starts) == 3
|
||||
# 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12]
|
||||
assert 0.0 <= assigned_starts[0] <= 2.0
|
||||
assert 5.0 <= assigned_starts[1] <= 7.0
|
||||
assert 10.0 <= assigned_starts[2] <= 12.0
|
||||
@@ -425,6 +425,12 @@ class TestSmartMatchEndpoint:
|
||||
for item in data["items"]:
|
||||
assert "quality" in item["breakdown"]
|
||||
assert "duration" in item["breakdown"]
|
||||
# P0 回归:扁平结构——素材字段在 item 顶层,无 asset 包装层
|
||||
for item in data["items"]:
|
||||
assert item["id"]
|
||||
assert "asset" not in item
|
||||
assert item["mime_type"].startswith("video/")
|
||||
assert "usable" in item
|
||||
|
||||
def test_limit_parameter(self):
|
||||
project, library, assets = _make_test_data()
|
||||
@@ -463,7 +469,7 @@ class TestSmartMatchEndpoint:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["asset"]["mime_type"] == "image/png"
|
||||
assert data["items"][0]["mime_type"] == "image/png"
|
||||
# total_candidates should only count filtered-by-kind assets (1 image, not 3 videos)
|
||||
assert data["total_candidates"] == 1
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""smart-match 过滤回退策略测试:余量过滤/高频排除导致结果集为空时必须回退。
|
||||
|
||||
线上事故:路由旧实现先 smart_select_assets(limit=N) 截取,再对这 N 条做
|
||||
usable / 高频过滤,过滤后不回补——排名靠前素材全部被排除时返回空 items,
|
||||
前端回退全选。修复后过滤全部前置,且每级过滤后为空/不足时回退保留。
|
||||
"""
|
||||
|
||||
import json
|
||||
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
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
|
||||
from app.api.routes.assets import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class _StubProjectRepo:
|
||||
def __init__(self, projects):
|
||||
self._projects = projects
|
||||
|
||||
def find_by_id(self, pid):
|
||||
return self._projects.get(pid)
|
||||
|
||||
|
||||
class _StubAssetLibraryRepo:
|
||||
def __init__(self, libraries):
|
||||
self._libraries = libraries
|
||||
|
||||
def get(self, lid):
|
||||
return self._libraries.get(lid)
|
||||
|
||||
|
||||
class _StubAssetRepo:
|
||||
"""模拟仓储;session 属性供 get_asset_recent_use_counts 使用(测试中会被 patch)。"""
|
||||
|
||||
def __init__(self, assets):
|
||||
self._assets = assets
|
||||
self.session = MagicMock(name="stub-session")
|
||||
|
||||
def find_by_library(self, lid, skip=0, limit=100, status=None):
|
||||
result = [a for a in self._assets if a.library_id == lid]
|
||||
if status:
|
||||
result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
|
||||
return result[skip : skip + limit]
|
||||
|
||||
def find_by_library_and_file_type(self, lid, file_type, skip=0, limit=100, status=None):
|
||||
result = [a for a in self._assets if a.library_id == lid and a.file_type == file_type]
|
||||
if status:
|
||||
result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
|
||||
return result[skip : skip + limit]
|
||||
|
||||
|
||||
def _make_app(asset_repo, lib_repo, proj_repo):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/assets")
|
||||
fake_user = MagicMock()
|
||||
fake_user.user = User(id="user-1", email="test@test.com", display_name="Test")
|
||||
app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=fake_user.user)
|
||||
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[get_asset_library_repository] = lambda: lib_repo
|
||||
app.dependency_overrides[get_project_repository] = lambda: proj_repo
|
||||
app.dependency_overrides[get_storage_service] = lambda: MagicMock()
|
||||
return app
|
||||
|
||||
|
||||
def _library():
|
||||
return AssetLibrary(id="lib-1", project_id="proj-1", name="Videos", kind=AssetLibraryKind.VIDEO)
|
||||
|
||||
|
||||
def _exhausted_ranges(duration=15.0):
|
||||
"""构造 used_time_ranges:整片覆盖 + 每区间 use_count 达上限 3 → usable=False。"""
|
||||
return [
|
||||
{"start": 0.0, "end": duration, "use_count": 3, "plan_id": "p1"},
|
||||
]
|
||||
|
||||
|
||||
def _video_asset(name, duration=15.0, quality=90, used_ranges=None):
|
||||
meta = {"used_time_ranges": used_ranges} if used_ranges is not None else {}
|
||||
return Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"key-{name}",
|
||||
mime_type="video/mp4",
|
||||
metadata=meta,
|
||||
quality_score=quality,
|
||||
duration=duration,
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
|
||||
|
||||
class TestSmartMatchAvailabilityFallback:
|
||||
"""余量过滤回退:全部素材 usable=False 时不返回空。"""
|
||||
|
||||
def test_all_exhausted_returns_assets_instead_of_empty(self):
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("v1.mp4", used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("v2.mp4", used_ranges=_exhausted_ranges(25)),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
# 回退:保留全部候选,不返回空
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total_candidates"] == 2
|
||||
|
||||
def test_mixed_exhausted_and_fresh_excludes_exhausted(self):
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("exhausted.mp4", quality=99, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("fresh.mp4", quality=50, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
assert "fresh.mp4" in names
|
||||
assert "exhausted.mp4" not in names
|
||||
|
||||
def test_limit_backfills_from_lower_ranked_when_top_exhausted(self):
|
||||
"""limit=1 且排名第一的素材耗尽时,必须回补排名靠后的可用素材,不返回空。"""
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("second-fresh.mp4", quality=40, used_ranges=None),
|
||||
_video_asset("third-fresh.mp4", quality=30, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 1})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
# 不能是空,也不能是耗尽的高分素材
|
||||
assert data["items"][0]["name"] == "second-fresh.mp4"
|
||||
|
||||
|
||||
class TestSmartMatchHighFreqFallback:
|
||||
"""高频排除回退:排除后为空/不足 limit 时保留全部可用素材。"""
|
||||
|
||||
def test_all_high_freq_keeps_all(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)]
|
||||
repo = _StubAssetRepo(assets)
|
||||
|
||||
# 全部素材在最近 5 个视频中使用 5 次(> 3)
|
||||
fake_counts = {assets[0].id: 5, assets[1].id: 5}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
# 全部高频 → 回退保留全部
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_high_freq_partial_exclusion_with_enough_remaining(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("hot.mp4", quality=99),
|
||||
_video_asset("cool1.mp4", quality=80),
|
||||
_video_asset("cool2.mp4", quality=70),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
fake_counts = {assets[0].id: 9, assets[1].id: 1, assets[2].id: 0}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
assert "hot.mp4" not in names
|
||||
assert set(names) == {"cool1.mp4", "cool2.mp4"}
|
||||
|
||||
def test_high_freq_insufficient_for_limit_keeps_all(self, monkeypatch):
|
||||
"""3 个素材、limit=5、2 个高频 → 剩余 1 < limit → 保留全部。"""
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("hot1.mp4", quality=99),
|
||||
_video_asset("hot2.mp4", quality=98),
|
||||
_video_asset("cool.mp4", quality=50),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
fake_counts = {assets[0].id: 8, assets[1].id: 7, assets[2].id: 0}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 5})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
# 剩余 1 < limit=5 → 回退保留全部 3 条
|
||||
assert set(names) == {"hot1.mp4", "hot2.mp4", "cool.mp4"}
|
||||
|
||||
def test_high_freq_query_failure_skips_exclusion(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
def _boom(db, asset_ids, recent_video_count=5):
|
||||
raise RuntimeError("DB down")
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)]
|
||||
repo = _StubAssetRepo(assets)
|
||||
monkeypatch.setattr(routes_mod, "get_asset_recent_use_counts", _boom)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(resp.json()["items"]) == 2
|
||||
@@ -0,0 +1,475 @@
|
||||
"""测试 smart_match 评分集成到素材选取路径。
|
||||
|
||||
验证:
|
||||
- 使用次数多的素材评分低于使用次数少的(unused 维度降权生效)
|
||||
- from-assets 路径中 sorted_candidates 按 smart_match 评分排序
|
||||
- 一键生成路径中 _sort_assets_by_smart_score 按评分降序
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
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")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset, smart_select_assets
|
||||
|
||||
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
"""Minimal Asset-like object."""
|
||||
|
||||
id: str
|
||||
quality_score: float | None = 70.0
|
||||
duration: float = 15.0
|
||||
created_at: datetime | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
status: str = "ready"
|
||||
file_type: str = "video"
|
||||
|
||||
|
||||
def _asset_with_use_count(asset_id: str, use_count: int) -> FakeAsset:
|
||||
"""创建指定使用次数的素材,其他维度保持一致。"""
|
||||
return FakeAsset(
|
||||
id=asset_id,
|
||||
quality_score=70.0,
|
||||
duration=15.0, # 最优区间 5-30s
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
metadata={"generation_use_count": use_count},
|
||||
)
|
||||
|
||||
|
||||
# ── score_asset 单元测试:unused 维度降权 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreAssetUnusedDiminsh:
|
||||
"""验证 unused 维度:使用次数越多,评分越低。"""
|
||||
|
||||
def test_unused_scores_higher_than_used(self):
|
||||
"""use_count=0 的素材评分高于 use_count>0 的。"""
|
||||
fresh = _asset_with_use_count("fresh", 0)
|
||||
used = _asset_with_use_count("used", 1)
|
||||
fresh_score, _ = score_asset(fresh)
|
||||
used_score, _ = score_asset(used)
|
||||
assert fresh_score > used_score
|
||||
|
||||
def test_high_use_count_scores_lower_than_low(self):
|
||||
"""use_count=5 的素材评分低于 use_count=1 的。"""
|
||||
low_use = _asset_with_use_count("low", 1)
|
||||
high_use = _asset_with_use_count("high", 5)
|
||||
low_score, _ = score_asset(low_use)
|
||||
high_score, _ = score_asset(high_use)
|
||||
assert low_score > high_score
|
||||
|
||||
def test_unused_breakdown_values(self):
|
||||
"""验证 unused 维度的具体分值。"""
|
||||
fresh = _asset_with_use_count("fresh", 0)
|
||||
low = _asset_with_use_count("low", 2)
|
||||
high = _asset_with_use_count("high", 10)
|
||||
|
||||
_, fresh_bd = score_asset(fresh)
|
||||
_, low_bd = score_asset(low)
|
||||
_, high_bd = score_asset(high)
|
||||
|
||||
# use_count=0 → unused_score=100 → component=10.0
|
||||
assert fresh_bd["unused"] == 10.0
|
||||
# use_count=2 → unused_score=70 → component=7.0
|
||||
assert low_bd["unused"] == 7.0
|
||||
# use_count=10 → unused_score=30 → component=3.0
|
||||
assert high_bd["unused"] == 3.0
|
||||
|
||||
def test_monotonically_decreasing_scores(self):
|
||||
"""使用次数递增时,总评分单调不增。"""
|
||||
scores = []
|
||||
for count in [0, 1, 2, 3, 5, 10, 50]:
|
||||
a = _asset_with_use_count(f"a{count}", count)
|
||||
s, _ = score_asset(a)
|
||||
scores.append(s)
|
||||
# 验证非递增
|
||||
for i in range(len(scores) - 1):
|
||||
assert (
|
||||
scores[i] >= scores[i + 1]
|
||||
), f"use_count 递增时评分应不增: scores[{i}]={scores[i]} < scores[{i+1}]={scores[i+1]}"
|
||||
|
||||
|
||||
# ── smart_select_assets 排序测试 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSmartSelectAssetsOrdering:
|
||||
"""验证 smart_select_assets 返回结果按评分降序。"""
|
||||
|
||||
def test_less_used_assets_ranked_higher(self):
|
||||
"""使用次数少的素材在结果中排名更高。"""
|
||||
assets = [
|
||||
_asset_with_use_count("heavily_used", 10),
|
||||
_asset_with_use_count("never_used", 0),
|
||||
_asset_with_use_count("lightly_used", 2),
|
||||
]
|
||||
results = smart_select_assets(assets)
|
||||
ids = [r.asset.id for r in results]
|
||||
# never_used 排第一,heavily_used 排最后
|
||||
assert ids[0] == "never_used"
|
||||
assert ids[-1] == "heavily_used"
|
||||
|
||||
def test_same_quality_different_use_count(self):
|
||||
"""质量相同时,使用次数少的排名更高。"""
|
||||
assets = [
|
||||
_asset_with_use_count("used_5", 5),
|
||||
_asset_with_use_count("used_0", 0),
|
||||
]
|
||||
results = smart_select_assets(assets)
|
||||
assert results[0].asset.id == "used_0"
|
||||
assert results[1].asset.id == "used_5"
|
||||
|
||||
|
||||
# ── from-assets 路径集成测试 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_mock_asset_for_clips(aid, duration, use_count=0):
|
||||
"""创建带 score_asset 所需属性的 mock 素材。"""
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = duration
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": use_count}
|
||||
return asset
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
auth.user.email = "test@example.com"
|
||||
auth.user.display_name = "测试用户"
|
||||
auth.user_id = "user-001"
|
||||
return auth
|
||||
|
||||
|
||||
def _make_zero_noise_patcher(module):
|
||||
"""构造 patch(module.random.uniform):噪声调用(上界=SCORE_RANDOM_NOISE_MAX)返回 0。
|
||||
|
||||
其他 uniform 调用(如片段时长随机)委托给一个独立的 Random 实例,
|
||||
避免递归回已 patch 的全局函数。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _patch_zero_noise_clips():
|
||||
"""消除 clips.py 排序噪声,其他 uniform 调用不受影响。"""
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
return _make_zero_noise_patcher(clips_module)
|
||||
|
||||
|
||||
def _patch_zero_noise_plan_service():
|
||||
"""消除 plan_generator_service.py 排序噪声,其他 uniform 调用不受影响。"""
|
||||
from app.services import plan_generator_service as svc_module
|
||||
|
||||
return _make_zero_noise_patcher(svc_module)
|
||||
|
||||
|
||||
class TestFromAssetsSmartMatchIntegration:
|
||||
"""验证 clips.py 中 sorted_candidates 使用 smart_match 评分。"""
|
||||
|
||||
def test_sorted_candidates_prefers_high_score_low_use_count(self):
|
||||
"""在 from-assets 路径中,smart_match 分高且使用次数少的素材排在前面。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
def _get_asset(aid):
|
||||
use_count = {"a_heavy": 10, "a_fresh": 0}[aid]
|
||||
return _make_mock_asset_for_clips(aid, 30.0, use_count)
|
||||
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
|
||||
with (
|
||||
_patch_zero_noise_clips(),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments,
|
||||
),
|
||||
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,
|
||||
),
|
||||
):
|
||||
body = ClipsFromAssetsRequest(
|
||||
asset_ids=["a_heavy", "a_fresh"],
|
||||
required_clips_count=2,
|
||||
)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="test-plan-001",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 验证 replace_all_clips_transactional 被调用
|
||||
assert mock_plan_svc.replace_all_clips_transactional.called
|
||||
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
||||
clips_data = call_args.args[1]
|
||||
|
||||
# 第一个片段应该分配给 a_fresh(smart_match 分更高)
|
||||
first_clip_asset = clips_data[0]["asset_id"]
|
||||
assert (
|
||||
first_clip_asset == "a_fresh"
|
||||
), f"第一个片段应分配给 smart_match 分更高的 a_fresh,实际是 {first_clip_asset}"
|
||||
|
||||
def test_score_noise_causes_varied_selection(self):
|
||||
"""得分接近(差距 < SCORE_RANDOM_NOISE_MAX)的素材,多次生成的素材组合应有变化。
|
||||
|
||||
两条同等质量/时长/新鲜度的素材(use_count 相同),smart_match 得分一致,
|
||||
噪声让两者的相对排名随机浮动,多次调用首个片段的素材分布应两者都出现。
|
||||
"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
def _get_asset(aid):
|
||||
return _make_mock_asset_for_clips(aid, 30.0, 0)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
|
||||
first_assets: set[str] = set()
|
||||
for _ in range(30):
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
|
||||
with (
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments,
|
||||
),
|
||||
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,
|
||||
),
|
||||
):
|
||||
body = ClipsFromAssetsRequest(
|
||||
asset_ids=["a_x", "a_y"],
|
||||
required_clips_count=2,
|
||||
)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=f"plan-noise-{len(first_assets)}-{_}",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = mock_plan_svc.replace_all_clips_transactional.call_args.args[1]
|
||||
first_assets.add(clips_data[0]["asset_id"])
|
||||
|
||||
assert first_assets == {
|
||||
"a_x",
|
||||
"a_y",
|
||||
}, f"噪声应使两条等分素材的排名浮动,30 次调用首个片段应覆盖两者,实际 {first_assets}"
|
||||
|
||||
|
||||
# ── 一键生成路径集成测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlanGeneratorSmartMatchIntegration:
|
||||
"""验证 PlanGeneratorService._sort_assets_by_smart_score 排序正确。"""
|
||||
|
||||
def test_sort_assets_by_smart_score_descending(self):
|
||||
"""_sort_assets_by_smart_score 返回按评分降序排列的素材 ID。"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
def _get_asset(aid):
|
||||
use_count = {"high_use": 10, "low_use": 0, "mid_use": 3}[aid]
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": use_count}
|
||||
return asset
|
||||
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
|
||||
db = MagicMock()
|
||||
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
|
||||
|
||||
with _patch_zero_noise_plan_service():
|
||||
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
|
||||
|
||||
# low_use (0次) 应排第一,high_use (10次) 应排最后
|
||||
assert sorted_ids[0] == "low_use"
|
||||
assert sorted_ids[-1] == "high_use"
|
||||
assert sorted_ids[1] == "mid_use"
|
||||
|
||||
def test_distribute_assets_uses_smart_score_ordering(self):
|
||||
"""_distribute_assets 在非随机模式下按 smart_match 评分排序素材。"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
def _get_asset(aid):
|
||||
use_count = {"old_asset": 10, "new_asset": 0}[aid]
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 30.0
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": use_count}
|
||||
return asset
|
||||
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
|
||||
db = MagicMock()
|
||||
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
|
||||
|
||||
# 创建 2 个 main clips(需要提供 id 参数)
|
||||
clips = [
|
||||
EditPlanClip(id="c1", plan_id="p1", clip_type="main", duration=5.0, order=0),
|
||||
EditPlanClip(id="c2", plan_id="p1", clip_type="main", duration=5.0, order=1),
|
||||
]
|
||||
|
||||
with (
|
||||
_patch_zero_noise_plan_service(),
|
||||
patch("app.services.plan_generator_service.distribute_assets") as mock_dist,
|
||||
):
|
||||
svc._distribute_assets(
|
||||
clips,
|
||||
["old_asset", "new_asset"],
|
||||
EditingMode.ONE_TAKE.value,
|
||||
random_selection=False,
|
||||
)
|
||||
# 验证传给 distribute_assets 的 asset_ids 按 smart_match 排序
|
||||
call_args = mock_dist.call_args
|
||||
passed_ids = call_args.args[1]
|
||||
# new_asset (0次使用) 应排在 old_asset (10次使用) 前面
|
||||
assert passed_ids[0] == "new_asset"
|
||||
assert passed_ids[1] == "old_asset"
|
||||
|
||||
def test_random_selection_skips_smart_score_sort(self):
|
||||
"""random_selection=True 时不执行 smart_match 排序。"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
db = MagicMock()
|
||||
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
|
||||
|
||||
clips = [
|
||||
EditPlanClip(id="c1", plan_id="p1", clip_type="main", duration=5.0, order=0),
|
||||
]
|
||||
|
||||
with patch("app.services.plan_generator_service.distribute_assets") as mock_dist:
|
||||
svc._distribute_assets(
|
||||
clips,
|
||||
["a1", "a2"],
|
||||
EditingMode.ONE_TAKE.value,
|
||||
random_selection=True,
|
||||
)
|
||||
# random_selection=True 时不应调用 asset_repo.get(不执行排序)
|
||||
mock_asset_repo.get.assert_not_called()
|
||||
|
||||
|
||||
class TestPlanGeneratorScoreNoise:
|
||||
"""验证一键生成路径的评分排序注入了随机噪声。"""
|
||||
|
||||
def test_equal_scores_produce_varied_order(self):
|
||||
"""两条 smart_match 得分相同的素材,多次排序的首位应覆盖两者。"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
def _get_asset(aid):
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": 0}
|
||||
return asset
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
|
||||
|
||||
first_ids: set[str] = set()
|
||||
for _ in range(30):
|
||||
order = svc._sort_assets_by_smart_score(["equal_a", "equal_b"])
|
||||
first_ids.add(order[0])
|
||||
|
||||
assert first_ids == {
|
||||
"equal_a",
|
||||
"equal_b",
|
||||
}, f"噪声应使等分素材排名浮动,30 次排序首位应覆盖两者,实际 {first_ids}"
|
||||
|
||||
def test_large_score_gap_not_flipped(self):
|
||||
"""得分差距远大于噪声上限时,低分素材不会因噪声超过高分素材。
|
||||
|
||||
quality 100 vs 0 → quality 维度差距 40 分 > 噪声上限 20,
|
||||
其余维度完全一致,50 次排序高质量素材必须始终排第一。
|
||||
"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
def _get_asset(aid):
|
||||
quality = {"top": 100.0, "bad": 0.0}[aid]
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = quality
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": 0}
|
||||
return asset
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
|
||||
|
||||
for _ in range(50):
|
||||
order = svc._sort_assets_by_smart_score(["top", "bad"])
|
||||
assert order[0] == "top", f"质量差距 40 分 > 噪声上限,top 应始终排第一,实际 {order}"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""统一模板 segments 数据源单元测试。
|
||||
|
||||
验证 template_repository 从 template_clip_configs 读取 segments,
|
||||
写入走 template_clip_configs,回退兼容 template_segments。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.template import Template, TemplateSegment
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(session):
|
||||
return SQLAlchemyTemplateRepository(session)
|
||||
|
||||
|
||||
def _make_template(template_id=None, user_id="u1", name="测试模板", mode="one_take"):
|
||||
tid = template_id or str(uuid.uuid4())
|
||||
return Template(
|
||||
id=tid,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
mode=mode,
|
||||
category="",
|
||||
tags=[],
|
||||
estimated_duration=30.0,
|
||||
is_active=True,
|
||||
segments=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_segment(template_id, order=1, material_type=None):
|
||||
return TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=template_id,
|
||||
segment_order=order,
|
||||
duration_min=5.0,
|
||||
duration_max=10.0,
|
||||
material_type=material_type,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateSegments:
|
||||
def test_writes_to_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).all()
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == "main"
|
||||
assert clips[0].order == 1
|
||||
assert clips[0].min_duration == 5.0
|
||||
|
||||
def test_material_type_stored_in_config(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
clip = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).first()
|
||||
assert clip.config["material_type"] == "voiceover"
|
||||
|
||||
|
||||
class TestListSegments:
|
||||
def test_reads_from_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].material_type == "voiceover"
|
||||
|
||||
def test_fallback_to_old_table(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=tpl.id,
|
||||
segment_order=1,
|
||||
duration_min=3.0,
|
||||
duration_max=8.0,
|
||||
material_type="场景",
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].material_type == "场景"
|
||||
|
||||
def test_clip_configs_takes_priority(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].duration_min == 5.0
|
||||
|
||||
|
||||
class TestListByUser:
|
||||
def test_batch_loads_from_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="人物")
|
||||
repo.create_segments([seg])
|
||||
result = repo.list_by_user("u1")
|
||||
assert len(result) == 1
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].material_type == "人物"
|
||||
|
||||
def test_fallback_for_old_data(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=2.0, duration_max=6.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_by_user("u1")
|
||||
assert len(result) == 1
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].duration_min == 2.0
|
||||
|
||||
|
||||
class TestCopyTemplate:
|
||||
def test_copy_writes_to_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
copied = repo.copy_template(tpl.id, "u1", "副本模板")
|
||||
assert copied.id != tpl.id
|
||||
clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == copied.id).all()
|
||||
assert len(clips) == 1
|
||||
assert clips[0].config["material_type"] == "voiceover"
|
||||
|
||||
def test_copy_empty_segments(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
copied = repo.copy_template(tpl.id, "u1", "空副本")
|
||||
assert len(copied.segments) == 0
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_cleans_both_tables(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
repo.delete(tpl.id, "u1")
|
||||
c1 = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).count()
|
||||
c2 = session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == tpl.id).count()
|
||||
assert c1 == 0
|
||||
assert c2 == 0
|
||||
|
||||
def test_delete_segments_by_template(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=2, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
count = repo.delete_segments_by_template(tpl.id)
|
||||
assert count == 2
|
||||
Reference in New Issue
Block a user