Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acec550e36 | |||
| c2ebe9d254 | |||
| 1d06d2ddd2 | |||
| 5e704094f6 | |||
| ffd99ffeb0 | |||
| 1b2bccee6f | |||
| a0cac1b75d | |||
| bbe831f9e0 | |||
| 4c5ab7f80e | |||
| 9b2e782abd | |||
| 788559ff29 | |||
| bdf99bba39 | |||
| bfe8bfe2da | |||
| fdcf48103e | |||
| 9e87ac05c6 | |||
| 8427bb6852 | |||
| ad86f5bc79 | |||
| e39f8bacdd | |||
| 8883581e34 | |||
| c80a6935fd | |||
| a6ae041944 | |||
| 082c9f6f09 | |||
| da4b95a40a | |||
| 7c541910b3 | |||
| e5d627fc3e | |||
| d8d1674ff0 | |||
| ad671d94c5 | |||
| efe7f6b52a | |||
| ce4f73d4de | |||
| 759c23c418 | |||
| 1e840057cb | |||
| 9aa450bb8b | |||
| 68a5c60911 | |||
| 841a168d30 | |||
| a7e346e200 | |||
| ef3a9fa61e | |||
| 20fa1ad589 | |||
| dddafbde83 | |||
| 6657b0fe19 | |||
| a25e0b6220 | |||
| b66de19be8 | |||
| 3db13fc1da | |||
| fecc786d4a | |||
| 4ab641f765 | |||
| 48e5077191 | |||
| 242497af8b | |||
| f5d3482fa9 | |||
| 8ea8a608f9 | |||
| 9fc82df6b9 | |||
| 81984ae938 | |||
| f7bcebecc4 | |||
| 8fa287036d | |||
| 1b5a315bd1 | |||
| ad5036c93a | |||
| 6d1f4da112 | |||
| 31c833745f | |||
| ab413413b7 | |||
| f5f24e828c | |||
| abc54ace4e | |||
| c947abc038 | |||
| 4328854f58 | |||
| ed972a230c | |||
| 949a5a3206 | |||
| 5ec71f5e1e | |||
| f8b004930d |
+8
-3
@@ -44,9 +44,14 @@ OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
|
||||
# ==================== CosyVoice 语音合成配置 ====================
|
||||
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
||||
COSYVOICE_MODEL=cosyvoice-v1
|
||||
COSYVOICE_VOICE=longxiaochun
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
COSYVOICE_FORMAT=mp3
|
||||
|
||||
Regular → Executable
+8
-3
@@ -42,10 +42,15 @@ OSS_DIRECT_UPLOAD_MAX_MB=2000
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||
|
||||
# ==================== CosyVoice 语音合成(必须配置)====================
|
||||
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||
COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
||||
COSYVOICE_MODEL=cosyvoice-v1
|
||||
COSYVOICE_VOICE=longxiaochun
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
COSYVOICE_FORMAT=mp3
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
max-line-length = 120
|
||||
exclude =
|
||||
.git,
|
||||
.cache,
|
||||
__pycache__,
|
||||
.venv,
|
||||
venv,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Auto Merge PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
top_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == top_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(top_prefix):
|
||||
member.name = name[len(top_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
+302
-15
@@ -22,7 +22,7 @@ permissions:
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: host
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python --version
|
||||
python3 --version
|
||||
python3 -m pip --version
|
||||
echo "CI environment is ready"
|
||||
|
||||
@@ -158,13 +158,195 @@ jobs:
|
||||
python3 scripts/check_migration_safety.py --allow-medium-risk
|
||||
fi
|
||||
|
||||
- name: Run unit tests
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: host
|
||||
timeout-minutes: 8
|
||||
|
||||
env:
|
||||
USE_IN_MEMORY_DB: "true"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
USE_IN_MEMORY_DB: "true"
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
pytest --version
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=60 > /dev/null
|
||||
|
||||
- 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="Unit Tests" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: host
|
||||
timeout-minutes: 20
|
||||
if: always()
|
||||
needs: validate
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: "false"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 --version
|
||||
python3 -m pip --version
|
||||
echo "CI environment is ready"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
pytest --version
|
||||
|
||||
- name: Start Redis
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}"
|
||||
echo "REDIS_CONTAINER=$REDIS_CONTAINER" >> "$GITHUB_ENV"
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" \
|
||||
-P \
|
||||
--health-cmd "redis-cli ping" \
|
||||
--health-interval 2s \
|
||||
--health-timeout 2s \
|
||||
--health-retries 10 \
|
||||
redis:7-alpine
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2)
|
||||
echo "Redis port: $REDIS_PORT"
|
||||
echo "REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 15); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "Redis is ready on port $REDIS_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Redis... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy
|
||||
|
||||
- name: Start PostgreSQL for integration tests
|
||||
shell: sh
|
||||
@@ -209,7 +391,14 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
pip install -q pytest-rerunfailures
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证
|
||||
|
||||
- name: Run API performance baseline tests
|
||||
shell: sh
|
||||
@@ -253,25 +442,44 @@ jobs:
|
||||
exit 0
|
||||
|
||||
|
||||
- name: Cleanup PostgreSQL
|
||||
- name: Cleanup PostgreSQL & Redis
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
|
||||
docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true
|
||||
echo "PostgreSQL container cleaned up"
|
||||
echo "Redis container cleaned up"
|
||||
|
||||
- name: Build summary
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
COVERAGE_THRESHOLD: "40"
|
||||
run: |
|
||||
set +e
|
||||
echo "=== 覆盖率汇总 ==="
|
||||
python3 scripts/ci_coverage_summary.py
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
echo "Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py
|
||||
|
||||
- name: Notify CI failure - Integration Tests
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Integration Tests" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: host
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
@@ -370,13 +578,22 @@ jobs:
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx vitest run src/test'
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py
|
||||
|
||||
deploy-staging:
|
||||
name: Build & Push Staging (Watchtower auto-deploy)
|
||||
runs-on: saas
|
||||
timeout-minutes: 30
|
||||
needs: [validate, frontend-lint]
|
||||
|
||||
if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/')
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -501,6 +718,23 @@ jobs:
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
|
||||
- name: Notify CI success
|
||||
if: success()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 成功通知 ==="
|
||||
SUCCESS_JOB="Staging部署成功" python3 scripts/ci_notify_success.py
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
@@ -569,6 +803,15 @@ jobs:
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Staging E2E Tests" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
@@ -634,6 +877,15 @@ jobs:
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Staging API Integration Tests" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
|
||||
build-production-runtime-images:
|
||||
name: Build Production Runtime Images
|
||||
@@ -714,6 +966,15 @@ jobs:
|
||||
echo "Disk usage after cleanup:"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: saas
|
||||
@@ -779,6 +1040,23 @@ jobs:
|
||||
|
||||
echo "$DEPLOY_B64" | base64 -d | ssh -p 22222 -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh"
|
||||
|
||||
- name: Notify CI success
|
||||
if: success()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 成功通知 ==="
|
||||
SUCCESS_JOB="生产部署成功" python3 scripts/ci_notify_success.py
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Deploy Production" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: saas
|
||||
@@ -847,3 +1125,12 @@ jobs:
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
|
||||
- name: Notify CI failure
|
||||
if: failure()
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Production Browser E2E" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Test SSH Secret
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- '.gitea/workflows/test-ssh-secret.yml'
|
||||
|
||||
jobs:
|
||||
test-ssh:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
which ssh || (apt-get update && apt-get install -y openssh-client)
|
||||
ssh -V
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
echo "=== Environment ==="
|
||||
echo "Runner hostname: $(hostname)"
|
||||
echo "Runner IP: $(hostname -i || echo 'unknown')"
|
||||
echo "Current user: $(whoami)"
|
||||
echo "=== Secrets check ==="
|
||||
if [ -n "$STAGING_SSH_HOST" ]; then
|
||||
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
|
||||
else
|
||||
echo "STAGING_SSH_HOST: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_USER" ]; then
|
||||
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
|
||||
else
|
||||
echo "STAGING_SSH_USER: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_KEY" ]; then
|
||||
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
|
||||
else
|
||||
echo "STAGING_SSH_KEY: [EMPTY]"
|
||||
fi
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
|
||||
echo "=== SSH Key fingerprint ==="
|
||||
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=10 \
|
||||
-o BatchMode=yes \
|
||||
-v \
|
||||
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
|
||||
echo "=== SSH Test Complete ==="
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
@@ -1,163 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: runtime-builder
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Show Python version
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python --version
|
||||
python -m pip --version
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
- name: Run unit tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
|
||||
- name: Run integration tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/integration -q --timeout=60 -x
|
||||
|
||||
lint:
|
||||
runs-on: runtime-builder
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
- name: Run Black (check only)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
|
||||
- name: Run Flake8
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
@@ -6,6 +6,7 @@ dist/
|
||||
coverage/
|
||||
|
||||
# Python / backend
|
||||
.cache/
|
||||
.venv/
|
||||
venv/
|
||||
.venv-ci-root/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Add editing_mode to edit_templates
|
||||
|
||||
Revision ID: 035_editing_mode
|
||||
Revises: 034_cms_enhance
|
||||
Create Date: 2026-07-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "035_editing_mode"
|
||||
down_revision = "034_cms_enhance"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_templates", "editing_mode")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Expand UUID fields from varchar(32) to varchar(36)
|
||||
|
||||
All UUID fields across all tables were varchar(32), but standard UUIDs with
|
||||
hyphens are 36 characters (e.g. 550e8400-e29b-41d4-a716-446655440000).
|
||||
This caused StringDataRightTruncation errors on insert.
|
||||
|
||||
Revision ID: 036_expand_uuid_36
|
||||
Revises: 035_editing_mode
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "036_expand_uuid_36"
|
||||
down_revision = "035_editing_mode"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# ── 表 → 需要扩容的列 ─────────────────────────────────────────────────────────
|
||||
|
||||
_TABLES: dict[str, list[str]] = {
|
||||
"projects": ["id", "owner_user_id"],
|
||||
"edit_templates": ["id"],
|
||||
"edit_plans": ["id", "template_id", "source_edit_plan_id", "project_id", "created_by_user_id"],
|
||||
"template_clip_configs": ["id", "template_id"],
|
||||
"edit_plan_clips": ["id", "plan_id", "template_clip_config_id", "asset_id"],
|
||||
"ingest_jobs": ["id", "project_id", "library_id", "result_asset_id"],
|
||||
"classification_jobs": ["id", "project_id", "asset_id"],
|
||||
"generation_tasks": [
|
||||
"id",
|
||||
"project_id",
|
||||
"strategy_id",
|
||||
"asset_library_id",
|
||||
"voice_library_id",
|
||||
"created_by_user_id",
|
||||
"source_edit_plan_id",
|
||||
"batch_id",
|
||||
],
|
||||
"generated_videos": ["id", "project_id", "generation_task_id", "duplicate_of"],
|
||||
"jobs": ["id", "project_id", "source_id", "created_by_user_id"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table, columns in _TABLES.items():
|
||||
for col in columns:
|
||||
op.alter_column(
|
||||
table,
|
||||
col,
|
||||
existing_type=sa.String(32),
|
||||
type_=sa.String(36),
|
||||
existing_nullable=None,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table, columns in reversed(list(_TABLES.items())):
|
||||
for col in columns:
|
||||
op.alter_column(
|
||||
table,
|
||||
col,
|
||||
existing_type=sa.String(36),
|
||||
type_=sa.String(32),
|
||||
existing_nullable=None,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Add logs field to generation_tasks
|
||||
|
||||
Revision ID: 037_generation_logs
|
||||
Revises: 036_expand_uuid_36
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "037_generation_logs"
|
||||
down_revision = "036_expand_uuid_36"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("logs", sa.Text(), nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "logs")
|
||||
@@ -8,10 +8,12 @@ from app.api.routes.dashboard import router as dashboard_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.edit_templates import router as edit_templates_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.jobs import router as jobs_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.recipes import router as recipes_router
|
||||
@@ -151,3 +153,11 @@ api_router.include_router(
|
||||
prefix="/tts",
|
||||
tags=["TTS"],
|
||||
)
|
||||
api_router.include_router(
|
||||
feature_flags_router,
|
||||
tags=["Internal"],
|
||||
)
|
||||
api_router.include_router(
|
||||
internal_render_router,
|
||||
tags=["Internal"],
|
||||
)
|
||||
|
||||
Regular → Executable
+152
-1
@@ -10,6 +10,8 @@ RESTful CRUD for EditPlan:
|
||||
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
|
||||
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
|
||||
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
|
||||
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
|
||||
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
|
||||
|
||||
业务逻辑委托给 EditPlanService 服务层。
|
||||
"""
|
||||
@@ -22,9 +24,10 @@ from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService
|
||||
from app.services import EditPlanService, PlanGeneratorService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -203,6 +206,44 @@ class GenerateCoverResponse(BaseModel):
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateFromTemplateRequest(BaseModel):
|
||||
"""基于模板生成剪辑计划请求体"""
|
||||
|
||||
template_id: str = Field(..., description="剪辑模板 ID")
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
project_id: str = Field(default="", description="所属项目 ID")
|
||||
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
|
||||
|
||||
|
||||
class _PlanClipItem(BaseModel):
|
||||
"""片段响应体"""
|
||||
|
||||
id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
asset_id: str
|
||||
text_content: str
|
||||
start_time: float
|
||||
duration: float
|
||||
transition_effect: str
|
||||
status: str
|
||||
config: Optional[dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GenerateFromTemplateResponse(BaseModel):
|
||||
"""基于模板生成剪辑计划响应体"""
|
||||
|
||||
plan: EditPlanResponse
|
||||
clips: List[_PlanClipItem]
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -604,6 +645,31 @@ def generate_plan(
|
||||
|
||||
# 创建 GenerationTask
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
|
||||
# 队列限流预检查(repository 不支持计数时跳过)
|
||||
user_id = current_user.user.id
|
||||
try:
|
||||
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
|
||||
gen_task_repo, "count_pending_total"
|
||||
)
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
gen_task = gen_task_use_case.execute(
|
||||
@@ -1078,3 +1144,88 @@ def get_plan_timeline(
|
||||
total_duration=total_duration,
|
||||
scenes=scenes,
|
||||
)
|
||||
|
||||
|
||||
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-from-template",
|
||||
response_model=GenerateFromTemplateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def generate_from_template(
|
||||
body: GenerateFromTemplateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> GenerateFromTemplateResponse:
|
||||
"""基于模板 + 素材自动生成剪辑计划
|
||||
|
||||
流程:
|
||||
1. 获取模板及其片段配置
|
||||
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
|
||||
3. 返回完整的计划和片段列表
|
||||
"""
|
||||
from app.services import EditTemplateService
|
||||
|
||||
# 项目鉴权
|
||||
if body.project_id:
|
||||
_check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||
|
||||
template_svc = EditTemplateService(db)
|
||||
|
||||
# 获取模板
|
||||
try:
|
||||
template = template_svc.get_template_or_raise(body.template_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
# 获取模板片段配置
|
||||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||
|
||||
# 调用 PlanGeneratorService 生成计划
|
||||
generator = PlanGeneratorService(db)
|
||||
result = generator.generate_from_template(
|
||||
template=template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=body.asset_ids,
|
||||
project_id=body.project_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
name=body.name,
|
||||
)
|
||||
|
||||
plan = result["plan"]
|
||||
clips = result["clips"]
|
||||
|
||||
logger.info(
|
||||
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
|
||||
plan.id,
|
||||
body.template_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateFromTemplateResponse(
|
||||
plan=_to_response(plan),
|
||||
clips=[
|
||||
_PlanClipItem(
|
||||
id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
asset_id=c.asset_id,
|
||||
text_content=c.text_content,
|
||||
start_time=c.start_time,
|
||||
duration=c.duration,
|
||||
transition_effect=c.transition_effect,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
config=c.config,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
for c in clips
|
||||
],
|
||||
)
|
||||
|
||||
@@ -41,6 +41,9 @@ class EditTemplateCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
description: str = Field(default="", max_length=2000, description="模板描述")
|
||||
template_type: str = Field(default="default", max_length=50, description="模板类型")
|
||||
editing_mode: str = Field(
|
||||
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
|
||||
)
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
|
||||
preview_url: str = Field(default="", max_length=500, description="预览地址")
|
||||
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
|
||||
@@ -52,6 +55,9 @@ class EditTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
|
||||
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
|
||||
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
|
||||
editing_mode: Optional[str] = Field(
|
||||
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
|
||||
)
|
||||
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
|
||||
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
|
||||
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
|
||||
@@ -65,6 +71,7 @@ class EditTemplateResponse(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
template_type: str
|
||||
editing_mode: str
|
||||
config: dict[str, Any]
|
||||
preview_url: str
|
||||
sort_weight: int
|
||||
@@ -102,6 +109,7 @@ def _to_response(t: EditTemplate) -> EditTemplateResponse:
|
||||
name=t.name,
|
||||
description=t.description,
|
||||
template_type=t.template_type,
|
||||
editing_mode=t.editing_mode,
|
||||
config=t.config,
|
||||
preview_url=t.preview_url,
|
||||
sort_weight=t.sort_weight,
|
||||
@@ -195,6 +203,7 @@ def create_template(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
editing_mode=body.editing_mode,
|
||||
config=normalized_config,
|
||||
preview_url=body.preview_url,
|
||||
sort_weight=body.sort_weight,
|
||||
@@ -239,6 +248,7 @@ def update_template(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
editing_mode=body.editing_mode,
|
||||
config=config_to_update,
|
||||
preview_url=body.preview_url,
|
||||
sort_weight=body.sort_weight,
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
"""Feature Flag 内部管理接口。
|
||||
|
||||
通过内部 API Key 鉴权,支持查看和修改 Feature Flag 配置。
|
||||
主要用于灰度发布期间的动态开关控制。
|
||||
|
||||
API:
|
||||
GET /api/v1/internal/feature-flags - 列出所有 flag
|
||||
GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
|
||||
PUT /api/v1/internal/feature-flags/{name} - 设置 flag 配置
|
||||
DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
|
||||
|
||||
鉴权:X-API-Key header,走内部 API Key 验证
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes.auth import _verify_internal_api_key
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FEATURE_FLAG_REDIS_PREFIX,
|
||||
FeatureFlagConfig,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||
|
||||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||
ALLOWED_FLAGS = {
|
||||
"render_engine",
|
||||
}
|
||||
|
||||
|
||||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||
"""获取 Feature Flag 存储实例。"""
|
||||
return RedisFeatureFlagStore(redis_url=settings.REDIS_URL)
|
||||
|
||||
|
||||
class FeatureFlagUpdateRequest(BaseModel):
|
||||
"""Feature Flag 更新请求体。"""
|
||||
|
||||
enabled: bool = Field(..., description="是否启用")
|
||||
percentage: int = Field(0, ge=0, le=100, description="灰度百分比 (0-100)")
|
||||
whitelist: list[str] = Field(default_factory=list, description="白名单列表(如 user_id)")
|
||||
|
||||
|
||||
class FeatureFlagResponse(BaseModel):
|
||||
"""Feature Flag 响应。"""
|
||||
|
||||
name: str
|
||||
enabled: bool
|
||||
percentage: int
|
||||
whitelist: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: FeatureFlagConfig) -> "FeatureFlagResponse":
|
||||
return cls(
|
||||
name=config.name,
|
||||
enabled=config.enabled,
|
||||
percentage=config.percentage,
|
||||
whitelist=sorted(config.whitelist),
|
||||
)
|
||||
|
||||
|
||||
class FeatureFlagCheckResponse(BaseModel):
|
||||
"""Flag 激活检查响应。"""
|
||||
|
||||
name: str
|
||||
active: bool
|
||||
identifier: Optional[str] = None
|
||||
|
||||
|
||||
def _validate_flag_name(name: str) -> None:
|
||||
"""校验 flag 名称是否在允许列表中。"""
|
||||
if name not in ALLOWED_FLAGS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported flag: {name}. Allowed: {sorted(ALLOWED_FLAGS)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[FeatureFlagResponse])
|
||||
async def list_feature_flags(
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""列出所有 Feature Flag。"""
|
||||
try:
|
||||
flags = store.list_all()
|
||||
# 同时返回预定义的 flag(即使未设置也显示默认值)
|
||||
result = []
|
||||
for name in sorted(ALLOWED_FLAGS):
|
||||
config = flags.get(name) or FeatureFlagConfig(name=name, enabled=False)
|
||||
result.append(FeatureFlagResponse.from_config(config))
|
||||
# 加上已存在但不在白名单中的 flag(只读展示)
|
||||
for name, config in flags.items():
|
||||
if name not in ALLOWED_FLAGS:
|
||||
result.append(FeatureFlagResponse.from_config(config))
|
||||
return sorted(result, key=lambda x: x.name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list feature flags: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||||
async def get_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""获取单个 Feature Flag 配置。"""
|
||||
try:
|
||||
config = store.get(name)
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to get feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
|
||||
|
||||
|
||||
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||||
async def check_feature_flag(
|
||||
name: str,
|
||||
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""检查某个标识符是否命中 Feature Flag。"""
|
||||
try:
|
||||
active = store.is_active(name, identifier=identifier)
|
||||
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to check feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||||
async def update_feature_flag(
|
||||
name: str,
|
||||
request: FeatureFlagUpdateRequest,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""更新 Feature Flag 配置。
|
||||
|
||||
只允许修改 ALLOWED_FLAGS 列表中的 flag。
|
||||
"""
|
||||
_validate_flag_name(name)
|
||||
|
||||
try:
|
||||
config = FeatureFlagConfig(
|
||||
name=name,
|
||||
enabled=request.enabled,
|
||||
percentage=request.percentage,
|
||||
whitelist=set(request.whitelist),
|
||||
)
|
||||
store.set(config)
|
||||
logger.info(
|
||||
"Feature flag updated: name=%s enabled=%s percentage=%d whitelist=%d",
|
||||
name,
|
||||
config.enabled,
|
||||
config.percentage,
|
||||
len(config.whitelist),
|
||||
)
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to update feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""删除 Feature Flag。
|
||||
|
||||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||
"""
|
||||
_validate_flag_name(name)
|
||||
|
||||
try:
|
||||
deleted = store.delete(name)
|
||||
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
|
||||
Regular → Executable
+151
-28
@@ -1,9 +1,18 @@
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
check_queue_limits,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
@@ -30,8 +39,9 @@ from packages.application import (
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
@@ -56,6 +66,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
@@ -63,7 +74,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
)
|
||||
|
||||
|
||||
def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
project_id=item.project_id,
|
||||
@@ -76,6 +87,7 @@ def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -179,19 +191,37 @@ def create_generation_task(
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
authenticated_user.user.id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.asset_select_mode,
|
||||
request.count,
|
||||
)
|
||||
|
||||
try:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
)
|
||||
except HTTPException as e:
|
||||
logger.warning("[生成任务] 校验失败: %s", e.detail)
|
||||
raise
|
||||
|
||||
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
|
||||
resolved_asset_ids: list[str] = list(request.asset_ids)
|
||||
if asset_library_id:
|
||||
library = asset_library_repository.get(asset_library_id)
|
||||
if library is None or (project_id and library.project_id != project_id):
|
||||
logger.warning("[生成任务] 素材库不存在: library_id=%s", asset_library_id)
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
|
||||
|
||||
assets = asset_repository.find_by_library(asset_library_id)
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
try:
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
except HTTPException as e:
|
||||
logger.warning("[生成任务] 素材校验失败: %s", e.detail)
|
||||
raise
|
||||
|
||||
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
|
||||
if not resolved_asset_ids:
|
||||
@@ -204,30 +234,85 @@ def create_generation_task(
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
failed_tasks = []
|
||||
user_id = authenticated_user.user.id
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
# 预检查:批量提交前先看会不会超限,避免建一半才拒
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + count > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(
|
||||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
created_tasks.append(task)
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks]
|
||||
try:
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[生成任务]",
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded:
|
||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
)
|
||||
break
|
||||
except GlobalQueueFull:
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@@ -265,6 +350,7 @@ def list_generation_results(
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
@@ -273,7 +359,11 @@ def list_generation_results(
|
||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
items = use_case.execute(task_id)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
responses = []
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
|
||||
@@ -292,6 +382,21 @@ def retry_generation_task(
|
||||
if status_val != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查:创建前判断,>= 上限就拒绝
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
@@ -303,10 +408,28 @@ def retry_generation_task(
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[生成任务]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
"""渲染结果内部下载接口。
|
||||
|
||||
通过内部 API Key 鉴权,为灰度对比工具等内部系统提供渲染结果下载能力。
|
||||
|
||||
API:
|
||||
GET /api/v1/internal/render/videos/{video_id}/download-url - 获取单个视频下载URL
|
||||
GET /api/v1/internal/render/tasks/{task_id}/videos - 获取任务下所有视频及下载URL
|
||||
|
||||
鉴权:X-API-Key header,走内部 API Key 验证
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.auth import _verify_internal_api_key
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_generated_video_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/render", tags=["Internal"])
|
||||
|
||||
|
||||
class InternalRenderVideoItem(BaseModel):
|
||||
"""内部渲染视频项。"""
|
||||
|
||||
video_id: str
|
||||
generation_task_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
status: str
|
||||
download_url: str
|
||||
|
||||
|
||||
class InternalRenderTaskVideosResponse(BaseModel):
|
||||
"""任务下所有渲染视频响应。"""
|
||||
|
||||
task_id: str
|
||||
count: int
|
||||
videos: list[InternalRenderVideoItem]
|
||||
|
||||
|
||||
class InternalRenderDownloadUrlResponse(BaseModel):
|
||||
"""单个视频下载URL响应。"""
|
||||
|
||||
video_id: str
|
||||
download_url: str
|
||||
|
||||
|
||||
def _video_to_item(video: Any, download_url: str) -> InternalRenderVideoItem:
|
||||
"""将 GeneratedVideo 领域对象转为响应项。"""
|
||||
return InternalRenderVideoItem(
|
||||
video_id=video.id,
|
||||
generation_task_id=video.generation_task_id,
|
||||
project_id=video.project_id,
|
||||
name=video.name,
|
||||
file_url=video.file_url,
|
||||
file_size=getattr(video, "file_size", None),
|
||||
duration=getattr(video, "duration", None),
|
||||
width=getattr(video, "width", None),
|
||||
height=getattr(video, "height", None),
|
||||
fps=getattr(video, "fps", None),
|
||||
status=video.status,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}/download-url", response_model=InternalRenderDownloadUrlResponse)
|
||||
def get_render_video_download_url(
|
||||
video_id: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> InternalRenderDownloadUrlResponse:
|
||||
"""获取单个渲染视频的下载URL(预签名)。"""
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
logger.info("内部渲染下载URL生成: video_id=%s", video_id)
|
||||
return InternalRenderDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/videos", response_model=InternalRenderTaskVideosResponse)
|
||||
def get_render_task_videos(
|
||||
task_id: str,
|
||||
status: str | None = Query(None, description="按状态筛选,如 completed/failed"),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> InternalRenderTaskVideosResponse:
|
||||
"""获取生成任务下所有渲染视频及下载URL。"""
|
||||
videos = generated_video_repository.list_by_generation_task(task_id)
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
videos = [v for v in videos if v.status == status]
|
||||
|
||||
items = []
|
||||
for video in videos:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
items.append(_video_to_item(video, download_url))
|
||||
|
||||
logger.info("内部渲染任务视频查询: task_id=%s count=%d", task_id, len(items))
|
||||
return InternalRenderTaskVideosResponse(
|
||||
task_id=task_id,
|
||||
count=len(items),
|
||||
videos=items,
|
||||
)
|
||||
Regular → Executable
+73
-5
@@ -1,7 +1,15 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
@@ -22,8 +30,9 @@ from packages.application import (
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
@@ -139,6 +148,21 @@ def retry_task_by_id(
|
||||
if _status_value(task.status) != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
@@ -150,10 +174,24 @@ def retry_task_by_id(
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
):
|
||||
logger.warning("[任务中心] 用户级重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return UserTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
@@ -221,6 +259,22 @@ def retry_project_task(
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
if _status_value(task.status) != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
@@ -232,10 +286,24 @@ def retry_project_task(
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
):
|
||||
logger.warning("[任务中心] 项目级重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _generation_task_to_project_response(retried)
|
||||
if task_type == "ingest":
|
||||
job = ingest_job_repository.get(source_id)
|
||||
|
||||
Regular → Executable
+17
-6
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_user_repository,
|
||||
@@ -56,7 +57,10 @@ def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTS
|
||||
return SQLAlchemyTTSJobRepository(session)
|
||||
|
||||
|
||||
def _to_response(job) -> TTSJobResponse:
|
||||
def _to_response(job, sign_url=None) -> TTSJobResponse:
|
||||
output_url = job.output_audio_url
|
||||
if sign_url and output_url:
|
||||
output_url = sign_url(output_url)
|
||||
return TTSJobResponse(
|
||||
id=job.id,
|
||||
user_id=job.user_id,
|
||||
@@ -66,7 +70,7 @@ def _to_response(job) -> TTSJobResponse:
|
||||
project_id=job.project_id,
|
||||
voice_clone_profile_id=job.voice_clone_profile_id,
|
||||
status=job.status,
|
||||
output_audio_url=job.output_audio_url,
|
||||
output_audio_url=output_url,
|
||||
output_audio_key=job.output_audio_key,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
@@ -176,6 +180,7 @@ def list_tts_jobs(
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> ListTTSJobResponse:
|
||||
"""列出用户的 TTS 合成任务。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -183,7 +188,7 @@ def list_tts_jobs(
|
||||
skip = (page - 1) * page_size
|
||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
|
||||
return ListTTSJobResponse(
|
||||
items=[_to_response(j) for j in items],
|
||||
items=[_to_response(j, sign_url) for j in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -195,6 +200,7 @@ def get_tts_job(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> TTSJobResponse:
|
||||
"""获取 TTS 任务详情。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -203,7 +209,7 @@ def get_tts_job(
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
return _to_response(job)
|
||||
return _to_response(job, sign_url)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
|
||||
@@ -211,6 +217,7 @@ def get_tts_job_status(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> TTSStatusResponse:
|
||||
"""查询 TTS 合成状态(用于前端轮询)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -219,10 +226,13 @@ def get_tts_job_status(
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
output_url = job.output_audio_url
|
||||
if output_url:
|
||||
output_url = sign_url(output_url)
|
||||
return TTSStatusResponse(
|
||||
id=job.id,
|
||||
status=job.status,
|
||||
output_audio_url=job.output_audio_url,
|
||||
output_audio_url=output_url,
|
||||
error_message=job.error_message,
|
||||
duration=job.duration,
|
||||
retry_count=job.retry_count,
|
||||
@@ -258,6 +268,7 @@ def save_tts_job_to_library(
|
||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> SaveToLibraryResponse:
|
||||
"""将已完成的 TTS 合成结果保存到配音库。
|
||||
|
||||
@@ -328,7 +339,7 @@ def save_tts_job_to_library(
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
name=item.name,
|
||||
audio_url=item.audio_url,
|
||||
audio_url=sign_url(item.audio_url) if item.audio_url else "",
|
||||
duration=item.duration,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
|
||||
@@ -38,6 +38,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||
return VoiceCloneProfileResponse(
|
||||
id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
|
||||
Regular → Executable
+22
-10
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||
from app.schemas.voice import (
|
||||
PresetVoiceItemResponse,
|
||||
PresetVoiceListResponse,
|
||||
@@ -50,7 +50,10 @@ def _get_clone_profile_repository(session: Session = Depends(get_db_session)) ->
|
||||
return SQLAlchemyVoiceCloneProfileRepository(session)
|
||||
|
||||
|
||||
def _to_response(item) -> VoiceLibraryItemResponse:
|
||||
def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
|
||||
audio = item.audio_url
|
||||
if sign_url and audio:
|
||||
audio = sign_url(audio)
|
||||
return VoiceLibraryItemResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
@@ -59,7 +62,7 @@ def _to_response(item) -> VoiceLibraryItemResponse:
|
||||
voice_provider=item.voice_provider,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
audio_url=item.audio_url,
|
||||
audio_url=audio,
|
||||
duration=item.duration,
|
||||
file_size=item.file_size,
|
||||
status=item.status,
|
||||
@@ -70,16 +73,20 @@ def _to_response(item) -> VoiceLibraryItemResponse:
|
||||
)
|
||||
|
||||
|
||||
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
|
||||
def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None) -> UnifiedVoiceItemResponse:
|
||||
"""将数据库音色转换为统一响应格式。
|
||||
|
||||
Args:
|
||||
item: VoiceLibraryItem
|
||||
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
|
||||
sign_url: 音频URL预签名函数
|
||||
"""
|
||||
profile_id = None
|
||||
if profile_id_map and item.voice_id:
|
||||
profile_id = profile_id_map.get(item.voice_id)
|
||||
audio = item.audio_url
|
||||
if sign_url and audio:
|
||||
audio = sign_url(audio)
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=item.id,
|
||||
type="clone",
|
||||
@@ -89,7 +96,7 @@ def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoi
|
||||
language="zh-CN",
|
||||
voice_id=item.voice_id,
|
||||
voice_provider=item.voice_provider or "cosyvoice",
|
||||
audio_url=item.audio_url,
|
||||
audio_url=audio,
|
||||
duration=item.duration,
|
||||
file_size=item.file_size,
|
||||
status=item.status,
|
||||
@@ -140,6 +147,7 @@ def list_voices_unified(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> UnifiedVoiceListResponse:
|
||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||
|
||||
@@ -167,7 +175,7 @@ def list_voices_unified(
|
||||
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
||||
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
|
||||
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
|
||||
clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw]
|
||||
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
|
||||
|
||||
# 组装结果
|
||||
if type == "preset":
|
||||
@@ -224,6 +232,7 @@ def list_voices_legacy(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> ListVoiceLibraryResponse:
|
||||
"""原有配音列表接口(仅返回用户克隆音色)。
|
||||
|
||||
@@ -233,7 +242,7 @@ def list_voices_legacy(
|
||||
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||
return ListVoiceLibraryResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
items=[_to_response(i, sign_url) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -243,13 +252,14 @@ def get_voice(
|
||||
voice_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetVoiceLibraryUseCase(voice_repository)
|
||||
item = use_case.execute(voice_id, user_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item)
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -258,6 +268,7 @@ def create_voice(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = _get_user_plan(user_id, user_repository)
|
||||
@@ -283,7 +294,7 @@ def create_voice(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
return _to_response(item)
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
||||
@@ -292,6 +303,7 @@ def update_voice(
|
||||
request: UpdateVoiceLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = UpdateVoiceLibraryCommand(
|
||||
@@ -313,7 +325,7 @@ def update_voice(
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item)
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
|
||||
@@ -79,6 +79,27 @@ class Settings(BaseSettings):
|
||||
OSS_ACCESS_KEY_ID: str = ""
|
||||
OSS_ACCESS_KEY_SECRET: str = ""
|
||||
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||
|
||||
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_id(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_secret(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
|
||||
default=2000,
|
||||
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
|
||||
@@ -88,6 +109,9 @@ class Settings(BaseSettings):
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
RENDER_ENGINE: str = "legacy"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -34,13 +34,18 @@ class OSSStorageService:
|
||||
if has_key_id and has_key_secret:
|
||||
if oss2 is not None:
|
||||
try:
|
||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||
# 否则 sign_url 默认生成 HTTP URL。
|
||||
bucket_endpoint = settings.OSS_ENDPOINT
|
||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||
auth = oss2.Auth(
|
||||
settings.OSS_ACCESS_KEY_ID,
|
||||
settings.OSS_ACCESS_KEY_SECRET,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
settings.OSS_ENDPOINT,
|
||||
bucket_endpoint,
|
||||
settings.OSS_BUCKET_NAME,
|
||||
)
|
||||
logger.info(
|
||||
@@ -64,6 +69,26 @@ class OSSStorageService:
|
||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
||||
self.endpoint = settings.OSS_ENDPOINT
|
||||
|
||||
def diagnose(self) -> None:
|
||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||
key_id_display = (
|
||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||
)
|
||||
logger.info(
|
||||
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
key_id_display,
|
||||
)
|
||||
if self.bucket is None:
|
||||
logger.error(
|
||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||
)
|
||||
else:
|
||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
@@ -166,12 +191,26 @@ class OSSStorageService:
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
@@ -240,4 +279,5 @@ def get_storage_service() -> OSSStorageService:
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = OSSStorageService()
|
||||
_storage_service.diagnose()
|
||||
return _storage_service
|
||||
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
|
||||
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
|
||||
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
|
||||
|
||||
|
||||
class UserPendingLimitExceeded(Exception):
|
||||
"""用户 pending 任务数超限,返回 429。"""
|
||||
|
||||
def __init__(self, user_id: str, pending_count: int, limit: int):
|
||||
self.user_id = user_id
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
class GlobalQueueFull(Exception):
|
||||
"""全局限流,返回 503。"""
|
||||
|
||||
def __init__(self, pending_count: int, limit: int):
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
def check_queue_limits(
|
||||
user_id: str,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
user_pending_limit: int = USER_PENDING_LIMIT,
|
||||
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
|
||||
) -> None:
|
||||
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
|
||||
|
||||
边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
generation_task_repository: 任务仓储
|
||||
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
|
||||
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
|
||||
|
||||
Raises:
|
||||
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
|
||||
UserPendingLimitExceeded: 用户超限时抛出
|
||||
"""
|
||||
# 先查全局(系统级保护优先级更高)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if global_pending >= global_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 全局 pending 任务数超限: %d/%d, user_id=%s",
|
||||
global_pending,
|
||||
global_pending_limit,
|
||||
user_id,
|
||||
)
|
||||
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
|
||||
# 再查用户级
|
||||
if user_id:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
if user_pending >= user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
|
||||
user_id,
|
||||
user_pending,
|
||||
user_pending_limit,
|
||||
)
|
||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
|
||||
|
||||
|
||||
def _mark_task_failed_safely(
|
||||
task: Any,
|
||||
generation_task_repository: Any,
|
||||
log_prefix: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""安全地把任务标记为 failed,更新失败只打日志不崩溃。"""
|
||||
try:
|
||||
task.mark_failed(f"任务被限流拒绝: {reason}")
|
||||
generation_task_repository.update(task)
|
||||
except Exception as update_err:
|
||||
logger.error(
|
||||
"%s 限流后更新状态也失败: task_id=%s error=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
update_err,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def safe_enqueue_generation_task(
|
||||
task: Any,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
user_id: str = "",
|
||||
log_prefix: str = "[任务队列]",
|
||||
log_task_status: bool = False,
|
||||
user_pending_limit: int = USER_PENDING_LIMIT,
|
||||
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
|
||||
) -> bool:
|
||||
"""安全入队:入队前限流检查 → 发送 Celery 任务 → 入队后最终校验兜底。
|
||||
|
||||
边界说明:
|
||||
入队前检查用 > 而非 >=。因为调用此函数时 task 已经是 pending 状态并计入 DB,
|
||||
pending 总数包含了当前任务本身。pending > limit 等价于"其他任务数 >= limit",
|
||||
与预检查的 >= 语义一致(都是达到上限就拒绝新任务)。
|
||||
|
||||
入队后最终校验:发送 Celery 成功后再查一次 DB 计数,处理并发竞态场景
|
||||
(两个请求同时通过入队前检查,后到的那个在这里被兜住)。
|
||||
|
||||
Args:
|
||||
task: 生成任务对象,需有 id 属性和 mark_failed 方法(状态已为 pending)
|
||||
generation_task_repository: 任务仓储,用于更新状态
|
||||
user_id: 用户 ID,传了才做用户级限流检查
|
||||
log_prefix: 日志前缀,便于区分调用来源
|
||||
log_task_status: 成功日志中是否额外打印任务状态
|
||||
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
|
||||
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
|
||||
|
||||
Returns:
|
||||
True 表示入队成功,False 表示入队失败(已标记为 failed)
|
||||
|
||||
Raises:
|
||||
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
|
||||
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
|
||||
"""
|
||||
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
|
||||
|
||||
# 全局限流检查(始终生效)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if global_pending > global_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 全局 pending 任务数超限(入队前): %d/%d, user_id=%s",
|
||||
global_pending,
|
||||
global_pending_limit,
|
||||
user_id or "unknown",
|
||||
)
|
||||
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
# 用户级限流检查(传了 user_id 才做)
|
||||
if user_id:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
if user_pending > user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
|
||||
user_id,
|
||||
user_pending,
|
||||
user_pending_limit,
|
||||
)
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
# ── 发送 Celery 任务 ──
|
||||
try:
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"%s 入队失败,标记为失败: task_id=%s error=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
task.mark_failed(f"任务入队失败: {e}")
|
||||
generation_task_repository.update(task)
|
||||
except Exception as update_err:
|
||||
logger.error(
|
||||
"%s 入队失败后更新状态也失败: task_id=%s error=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
update_err,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
# ── 入队后最终校验:并发竞态兜底 ──
|
||||
# 发送成功后再查一次,防止两个请求同时通过入队前检查导致超限
|
||||
global_after = generation_task_repository.count_pending_total()
|
||||
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
|
||||
|
||||
global_over = global_after > global_pending_limit
|
||||
user_over = bool(user_id and user_after > user_pending_limit)
|
||||
|
||||
if global_over or user_over:
|
||||
if global_over:
|
||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
else:
|
||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||
|
||||
logger.warning(
|
||||
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
|
||||
reason,
|
||||
task.id,
|
||||
user_id or "unknown",
|
||||
)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, reason)
|
||||
raise exc
|
||||
|
||||
# 入队成功日志
|
||||
if log_task_status:
|
||||
logger.info(
|
||||
"%s 入队成功: task_id=%s, status=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
task.status,
|
||||
)
|
||||
else:
|
||||
logger.info("%s 入队成功: task_id=%s", log_prefix, task.id)
|
||||
return True
|
||||
Executable → Regular
+32
-2
@@ -201,7 +201,37 @@ def get_voice_clone_profile_repository(
|
||||
|
||||
|
||||
def get_cosyvoice_service():
|
||||
"""Provide the CosyVoice service instance."""
|
||||
"""Provide the CosyVoice service instance.
|
||||
|
||||
注入 OSS 音频URL预签名函数,确保私有bucket下的参考音频
|
||||
能被 CosyVoice 服务器下载。
|
||||
"""
|
||||
from app.core.storage import get_storage_service
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
|
||||
return CosyVoiceService()
|
||||
storage = get_storage_service()
|
||||
|
||||
def _sign_audio_url(url: str) -> str:
|
||||
"""对音频URL做预签名,私有bucket下 CosyVoice 服务器才能下载."""
|
||||
return storage.get_download_url(url, expires_seconds=86400)
|
||||
|
||||
return CosyVoiceService(audio_url_signer=_sign_audio_url)
|
||||
|
||||
|
||||
def get_audio_url_signer():
|
||||
"""提供音频URL预签名函数(24小时有效期)。
|
||||
|
||||
用于所有 API 返回给前端的音频 URL,确保私有 OSS bucket 下可正常访问。
|
||||
空 URL、非 OSS URL 直接原样返回;签名失败时回退到原始 URL。
|
||||
"""
|
||||
from app.core.storage import get_storage_service
|
||||
|
||||
storage = get_storage_service()
|
||||
|
||||
def sign_audio_url(url: str) -> str:
|
||||
if not url:
|
||||
return url
|
||||
return storage.get_download_url(url, expires_seconds=86400)
|
||||
|
||||
return sign_audio_url
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
@@ -62,6 +64,21 @@ class GenerationTaskResponse(BaseModel):
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
logs: list[dict] = Field(default_factory=list)
|
||||
|
||||
@field_validator("logs", mode="before")
|
||||
@classmethod
|
||||
def _parse_logs(cls, v: object) -> list[dict]:
|
||||
"""将 JSON 字符串解析为 list[dict]。"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return []
|
||||
|
||||
|
||||
class BatchGenerationTaskResponse(BaseModel):
|
||||
|
||||
@@ -4,6 +4,7 @@ from .auto_clip_service import AutoClipService
|
||||
from .edit_plan_service import EditPlanService
|
||||
from .edit_template_service import EditTemplateService
|
||||
from .job_service import JobService
|
||||
from .plan_generator_service import PlanGeneratorService
|
||||
from .video_compose_service import VideoComposeService
|
||||
|
||||
__all__ = [
|
||||
@@ -11,5 +12,6 @@ __all__ = [
|
||||
"EditPlanService",
|
||||
"EditTemplateService",
|
||||
"JobService",
|
||||
"PlanGeneratorService",
|
||||
"VideoComposeService",
|
||||
]
|
||||
|
||||
@@ -100,6 +100,7 @@ class EditTemplateService:
|
||||
*,
|
||||
description: str = "",
|
||||
template_type: str = "default",
|
||||
editing_mode: str = "one_take",
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
preview_url: str = "",
|
||||
sort_weight: int = 0,
|
||||
@@ -124,6 +125,7 @@ class EditTemplateService:
|
||||
name=clean_name,
|
||||
description=description,
|
||||
template_type=template_type,
|
||||
editing_mode=editing_mode,
|
||||
config=config,
|
||||
preview_url=preview_url,
|
||||
sort_weight=sort_weight,
|
||||
@@ -139,6 +141,7 @@ class EditTemplateService:
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
template_type: Optional[str] = None,
|
||||
editing_mode: Optional[str] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
preview_url: Optional[str] = None,
|
||||
sort_weight: Optional[int] = None,
|
||||
@@ -165,6 +168,7 @@ class EditTemplateService:
|
||||
name=new_name,
|
||||
description=description.strip() if description is not None else existing.description,
|
||||
template_type=template_type.strip() if template_type is not None else existing.template_type,
|
||||
editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode,
|
||||
config=config if config is not None else existing.config,
|
||||
preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
|
||||
sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""PlanGeneratorService — 基于模板+素材自动生成剪辑计划.
|
||||
|
||||
核心职责:
|
||||
- 根据 EditTemplate 的 editing_mode 和 TemplateClipConfig 列表,
|
||||
自动生成 EditPlan + EditPlanClip 列表
|
||||
- 四种模式素材分配策略:
|
||||
- ONE_TAKE: 素材顺序分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll),标记需要配音叠加
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.edit_template import EditTemplate
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
|
||||
_DEFAULT_CLIP_DURATION = 5.0
|
||||
_DEFAULT_INTRO_DURATION = 3.0
|
||||
_DEFAULT_OUTRO_DURATION = 3.0
|
||||
|
||||
|
||||
class PlanGeneratorService:
|
||||
"""剪辑计划生成器
|
||||
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_from_template(
|
||||
self,
|
||||
template: EditTemplate,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
asset_ids: List[str],
|
||||
*,
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""基于模板+素材生成剪辑计划
|
||||
|
||||
Args:
|
||||
template: 剪辑模板实体
|
||||
clip_configs: 模板片段配置列表(可为空,自动生成默认结构)
|
||||
asset_ids: 素材 ID 列表
|
||||
project_id: 所属项目 ID
|
||||
created_by_user_id: 创建者用户 ID
|
||||
name: 计划名称(为空则自动取模板名)
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
"""
|
||||
editing_mode = template.editing_mode or EditingMode.ONE_TAKE.value
|
||||
plan_name = name.strip() or f"{template.name} - 剪辑计划"
|
||||
|
||||
# 1. 构建 plan config(继承模板的 title/subtitle/bgm,记录 editing_mode)
|
||||
plan_config = self._build_plan_config(template, editing_mode)
|
||||
|
||||
# 2. 创建 EditPlan
|
||||
plan = EditPlan.create(
|
||||
template_id=template.id,
|
||||
name=plan_name,
|
||||
config=plan_config,
|
||||
total_duration=0.0,
|
||||
project_id=project_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
plan = self._plan_repo.create(plan)
|
||||
logger.info(
|
||||
"生成剪辑计划: plan_id=%s template=%s mode=%s assets=%d",
|
||||
plan.id,
|
||||
template.id,
|
||||
editing_mode,
|
||||
len(asset_ids),
|
||||
)
|
||||
|
||||
# 3. 生成片段列表
|
||||
if clip_configs:
|
||||
clips = self._create_clips_from_configs(plan.id, clip_configs)
|
||||
else:
|
||||
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: List[EditPlanClip] = []
|
||||
total_duration = 0.0
|
||||
for clip in clips:
|
||||
saved = self._clip_repo.create(clip)
|
||||
created_clips.append(saved)
|
||||
total_duration += saved.duration
|
||||
|
||||
# 6. 更新 plan 的 total_duration
|
||||
plan.total_duration = total_duration
|
||||
plan = self._plan_repo.update(plan)
|
||||
|
||||
# 7. 流转到 editing 状态
|
||||
try:
|
||||
plan.start_editing()
|
||||
plan = self._plan_repo.update(plan)
|
||||
except ValueError as exc:
|
||||
logger.warning("计划状态流转失败: plan_id=%s error=%s", plan.id, exc)
|
||||
|
||||
logger.info(
|
||||
"剪辑计划生成完成: plan_id=%s clips=%d duration=%.1f",
|
||||
plan.id,
|
||||
len(created_clips),
|
||||
total_duration,
|
||||
)
|
||||
|
||||
return {"plan": plan, "clips": created_clips}
|
||||
|
||||
# ── 内部方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_plan_config(
|
||||
self,
|
||||
template: EditTemplate,
|
||||
editing_mode: str,
|
||||
) -> dict[str, Any]:
|
||||
"""从模板配置构建 plan config"""
|
||||
template_config = template.config or {}
|
||||
plan_config: dict[str, Any] = {
|
||||
"editing_mode": editing_mode,
|
||||
}
|
||||
# 继承模板的 cover/title/subtitle/bgm 配置
|
||||
for key in ("cover", "title", "subtitle", "bgm"):
|
||||
if key in template_config:
|
||||
plan_config[key] = template_config[key]
|
||||
|
||||
return normalize_plan_config(plan_config)
|
||||
|
||||
def _create_clips_from_configs(
|
||||
self,
|
||||
plan_id: str,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
|
||||
clips: List[EditPlanClip] = []
|
||||
# 按 order 排序
|
||||
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||
|
||||
for cfg in sorted_configs:
|
||||
# 计算时长:取 min_duration 和 max_duration 的中间值
|
||||
if cfg.min_duration > 0 and cfg.max_duration > 0:
|
||||
duration = (cfg.min_duration + cfg.max_duration) / 2
|
||||
elif cfg.min_duration > 0:
|
||||
duration = cfg.min_duration
|
||||
elif cfg.max_duration > 0:
|
||||
duration = cfg.max_duration
|
||||
else:
|
||||
duration = _DEFAULT_CLIP_DURATION
|
||||
|
||||
# clip_type 可能是枚举或字符串
|
||||
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||||
|
||||
# transition_effect 可能是枚举或字符串
|
||||
transition = (
|
||||
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||||
)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
text_content=getattr(cfg, "text_template", "") or "",
|
||||
duration=duration,
|
||||
transition_effect=transition or "cut",
|
||||
)
|
||||
clips.append(clip)
|
||||
|
||||
return clips
|
||||
|
||||
def _generate_default_clips(
|
||||
self,
|
||||
plan_id: str,
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
|
||||
|
||||
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||
"""
|
||||
n = max(asset_count, 1)
|
||||
clips: List[EditPlanClip] = []
|
||||
order = 0
|
||||
|
||||
if editing_mode == EditingMode.PIP.value:
|
||||
# 1 个 main(全屏背景)
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 overlay
|
||||
for i in range(1, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="overlay",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
# N 个 main clips(B-roll)
|
||||
for i in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
config={"role": "b_roll"},
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
# 1 个 background
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="background",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 1 个 corner_voice
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="corner_voice",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 b_roll
|
||||
for i in range(2, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="b_roll",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
else:
|
||||
# ONE_TAKE: N 个 main clips
|
||||
for i in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
return clips
|
||||
|
||||
def _distribute_assets(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
|
||||
|
||||
分配策略:
|
||||
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll)
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
"""
|
||||
if not asset_ids or not clips:
|
||||
return
|
||||
|
||||
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||
self._distribute_one_take(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.PIP.value:
|
||||
self._distribute_pip(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
self._distribute_voice_over(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
self._distribute_voice_pip(clips, asset_ids)
|
||||
else:
|
||||
# 未知模式,退化为 one_take
|
||||
self._distribute_one_take(clips, asset_ids)
|
||||
|
||||
def _distribute_one_take(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
|
||||
def _distribute_pip(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
|
||||
# 第1个素材 → main clip
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
if main_clips and asset_ids:
|
||||
main_clips[0].assign_asset(asset_ids[0])
|
||||
|
||||
# 其余素材 → overlay clips
|
||||
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||||
remaining = asset_ids[1:]
|
||||
for i, clip in enumerate(overlay_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
|
||||
def _distribute_voice_over(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""VOICE_OVER: 素材→main clips (B-roll)"""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
|
||||
def _distribute_voice_pip(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
|
||||
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||||
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
|
||||
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
|
||||
|
||||
# 第1个素材 → background
|
||||
if bg_clips and len(asset_ids) > 0:
|
||||
bg_clips[0].assign_asset(asset_ids[0])
|
||||
|
||||
# 第2个素材 → corner_voice
|
||||
if corner_clips and len(asset_ids) > 1:
|
||||
corner_clips[0].assign_asset(asset_ids[1])
|
||||
|
||||
# 其余素材 → b_roll
|
||||
remaining = asset_ids[2:]
|
||||
for i, clip in enumerate(broll_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
@@ -1,7 +1,17 @@
|
||||
"""
|
||||
视频处理模块
|
||||
|
||||
轻量工具(ffmpeg_utils / oss_helpers / dedup_helpers)顶层直接导出,
|
||||
无额外依赖。渲染相关组件(UnifiedRenderService / RenderAdapter /
|
||||
VideoProcessor 等)按需从子模块导入,避免 __init__ 阶段引入
|
||||
packages / DB 等重依赖。
|
||||
"""
|
||||
|
||||
from .processor import VideoProcessor, VideoResult
|
||||
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
|
||||
from . import dedup_helpers, ffmpeg_utils, oss_helpers
|
||||
|
||||
__all__ = ["VideoProcessor", "VideoResult"]
|
||||
__all__ = [
|
||||
"ffmpeg_utils",
|
||||
"oss_helpers",
|
||||
"dedup_helpers",
|
||||
]
|
||||
|
||||
Regular → Executable
+7
-3
@@ -93,12 +93,16 @@ class VideoFingerprint:
|
||||
resolution: tuple[int, int]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
# 注意:color_histograms 里的值可能是 np.float32(来自 cv2.normalize),
|
||||
# 直接存进 dict 后 SQLAlchemy JSON 序列化会报 "float32 is not JSON serializable"。
|
||||
# 这里统一转成 Python 原生 float。
|
||||
native_histograms = [[float(v) for v in hist] for hist in self.color_histograms]
|
||||
return {
|
||||
"md5": self.md5,
|
||||
"keyframe_phashes": self.keyframe_phashes,
|
||||
"color_histograms": self.color_histograms,
|
||||
"duration": self.duration,
|
||||
"resolution": list(self.resolution),
|
||||
"color_histograms": native_histograms,
|
||||
"duration": float(self.duration),
|
||||
"resolution": [int(self.resolution[0]), int(self.resolution[1])],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
|
||||
|
||||
供 render_edit_plan 和 generate_video 共同复用,
|
||||
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_video_record_and_dedup(
|
||||
*,
|
||||
generation_task_id: str,
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
duration: float,
|
||||
video_path: str,
|
||||
mode: str,
|
||||
session: Session,
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
fps: float = 25.0,
|
||||
) -> int:
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
|
||||
|
||||
Args:
|
||||
generation_task_id: 生成任务 ID
|
||||
project_id: 项目 ID
|
||||
batch_id: 批次 ID(可为空字符串)
|
||||
file_url: 视频文件 URL
|
||||
file_size: 文件大小(字节)
|
||||
duration: 视频时长(秒)
|
||||
video_path: 视频本地路径(用于计算指纹)
|
||||
mode: 剪辑模式名称
|
||||
session: 数据库会话
|
||||
width: 视频宽度
|
||||
height: 视频高度
|
||||
fps: 视频帧率
|
||||
|
||||
Returns:
|
||||
创建的视频记录数量(1 表示成功,0 表示失败)
|
||||
"""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
try:
|
||||
video_id = uuid4().hex
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
generation_task_id=generation_task_id,
|
||||
name=f"generated-{generation_task_id[:8]}.mp4",
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
status="completed",
|
||||
generation_params={"mode": mode},
|
||||
)
|
||||
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
|
||||
# 计算视频指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
try:
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
except Exception as fp_err:
|
||||
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
|
||||
session.commit()
|
||||
return 1
|
||||
|
||||
generated_video.video_fingerprint = fingerprint.to_dict()
|
||||
|
||||
# (a) 历史成片查重
|
||||
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
|
||||
|
||||
if duplicate_result:
|
||||
generated_video.is_duplicate = True
|
||||
generated_video.duplicate_of = duplicate_result["duplicate_of"]
|
||||
logger.info(
|
||||
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
|
||||
video_id,
|
||||
duplicate_result["duplicate_of"],
|
||||
duplicate_result["reason"],
|
||||
duplicate_result["similarity"],
|
||||
)
|
||||
else:
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
video_repo.update(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"GeneratedVideo record created: %s (task=%s, dup=%s)",
|
||||
video_id,
|
||||
generation_task_id,
|
||||
generated_video.is_duplicate,
|
||||
)
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create video record / dedup for task %s: %s",
|
||||
generation_task_id,
|
||||
e,
|
||||
)
|
||||
session.rollback()
|
||||
return 0
|
||||
@@ -1,700 +0,0 @@
|
||||
"""
|
||||
视频剪辑模式处理器
|
||||
支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 从 domain 层导入 EditingMode,避免重复定义
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
|
||||
class PIPPosition(StrEnum):
|
||||
"""画中画位置枚举"""
|
||||
|
||||
TOP_LEFT = "top_left"
|
||||
TOP_RIGHT = "top_right"
|
||||
BOTTOM_LEFT = "bottom_left"
|
||||
BOTTOM_RIGHT = "bottom_right"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EditingModeConfig:
|
||||
"""剪辑模式配置"""
|
||||
|
||||
mode: EditingMode
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
output_fps: int = 25
|
||||
pip_position: PIPPosition = PIPPosition.TOP_RIGHT
|
||||
pip_scale: float = 0.25 # 画中画占主画面的比例
|
||||
transition_duration: float = 0.5 # 转场时长(秒)
|
||||
output_codec: str = "libx264"
|
||||
output_preset: str = "medium"
|
||||
output_crf: int = 23
|
||||
|
||||
|
||||
class EditingModeProcessor:
|
||||
"""剪辑模式处理器"""
|
||||
|
||||
def __init__(self, config: EditingModeConfig, work_dir: Optional[str] = None):
|
||||
"""
|
||||
初始化剪辑模式处理器
|
||||
|
||||
Args:
|
||||
config: 剪辑模式配置
|
||||
work_dir: 工作目录,默认使用系统临时目录
|
||||
"""
|
||||
self.config = config
|
||||
self.work_dir = work_dir or tempfile.gettempdir()
|
||||
self._ffmpeg_bin = "ffmpeg"
|
||||
self._ffprobe_bin = "ffprobe"
|
||||
|
||||
def process(
|
||||
self,
|
||||
video_paths: list[str],
|
||||
audio_path: Optional[str] = None,
|
||||
output_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
根据模式处理视频,返回输出文件路径
|
||||
|
||||
Args:
|
||||
video_paths: 视频素材路径列表
|
||||
audio_path: 音频路径(用于口播模式)
|
||||
output_path: 输出文件路径,默认自动生成
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if not video_paths:
|
||||
raise ValueError("video_paths cannot be empty")
|
||||
|
||||
self._validate_inputs(video_paths, audio_path)
|
||||
|
||||
if output_path is None:
|
||||
output_path = self._generate_output_path()
|
||||
|
||||
logger.info(f"Processing videos with mode: {self.config.mode}, count: {len(video_paths)}")
|
||||
|
||||
try:
|
||||
if self.config.mode == EditingMode.ONE_TAKE:
|
||||
return self._one_take(video_paths, output_path)
|
||||
elif self.config.mode == EditingMode.PIP:
|
||||
return self._pip(video_paths, output_path)
|
||||
elif self.config.mode == EditingMode.VOICE_OVER:
|
||||
return self._voice_over(video_paths, audio_path, output_path)
|
||||
elif self.config.mode == EditingMode.VOICE_PIP:
|
||||
return self._voice_pip(video_paths, audio_path, output_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported editing mode: {self.config.mode}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing videos: {e}")
|
||||
raise
|
||||
|
||||
def _validate_inputs(self, video_paths: list[str], audio_path: Optional[str]) -> None:
|
||||
"""验证输入文件"""
|
||||
for path in video_paths:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"Video file not found: {path}")
|
||||
if not os.path.getsize(path) > 0:
|
||||
raise ValueError(f"Video file is empty: {path}")
|
||||
|
||||
if audio_path and not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
def _generate_output_path(self) -> str:
|
||||
"""生成输出文件路径"""
|
||||
os.makedirs(self.work_dir, exist_ok=True)
|
||||
return os.path.join(self.work_dir, f"output_{self.config.mode}_{os.getpid()}.mp4")
|
||||
|
||||
def _run_ffmpeg(self, command: list[str], capture_output: bool = True) -> tuple:
|
||||
"""执行 FFmpeg 命令"""
|
||||
logger.debug(f"Running FFmpeg: {' '.join(command)}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=capture_output,
|
||||
)
|
||||
return result.stdout or "", result.stderr or ""
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr.decode() if e.stderr else str(e)
|
||||
logger.error(f"FFmpeg error: {stderr}")
|
||||
raise RuntimeError(f"FFmpeg execution failed: {stderr}") from e
|
||||
|
||||
def _get_video_info(self, video_path: str) -> dict:
|
||||
"""获取视频信息"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
self._ffprobe_bin,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration,codec_name",
|
||||
"-show_entries",
|
||||
"format=duration,size",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get("streams", [{}])
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
|
||||
fmt = data.get("format", {})
|
||||
|
||||
fps_str = video_stream.get("r_frame_rate", "25/1")
|
||||
fps_parts = fps_str.split("/")
|
||||
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0])
|
||||
|
||||
return {
|
||||
"width": int(video_stream.get("width", 0)),
|
||||
"height": int(video_stream.get("height", 0)),
|
||||
"fps": fps,
|
||||
"duration": float(fmt.get("duration", 0)),
|
||||
"codec": video_stream.get("codec_name", "unknown"),
|
||||
"size": int(fmt.get("size", 0)),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get video info for {video_path}: {e}")
|
||||
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
|
||||
|
||||
def _get_pip_position_offset(
|
||||
self, main_width: int, main_height: int, pip_width: int, pip_height: int
|
||||
) -> tuple[int, int]:
|
||||
"""获取画中画位置偏移量"""
|
||||
margin = 10
|
||||
position_offsets = {
|
||||
PIPPosition.TOP_LEFT: (margin, margin),
|
||||
PIPPosition.TOP_RIGHT: (main_width - pip_width - margin, margin),
|
||||
PIPPosition.BOTTOM_LEFT: (margin, main_height - pip_height - margin),
|
||||
PIPPosition.BOTTOM_RIGHT: (main_width - pip_width - margin, main_height - pip_height - margin),
|
||||
}
|
||||
return position_offsets.get(self.config.pip_position, position_offsets[PIPPosition.TOP_RIGHT])
|
||||
|
||||
def _normalize_video(self, input_path: str, output_path: str) -> dict:
|
||||
"""标准化视频格式:先统一帧率,再缩放/填充"""
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-r",
|
||||
str(self.config.output_fps), # 先统一帧率
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r",
|
||||
str(self.config.output_fps),
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-an",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return self._get_video_info(output_path)
|
||||
|
||||
def _one_take(self, video_paths: list[str], output_path: str) -> str:
|
||||
"""一镜到底模式:顺序拼接视频,添加淡入淡出转场"""
|
||||
if len(video_paths) == 1:
|
||||
return self._normalize_video(video_paths[0], output_path)
|
||||
|
||||
normalized_paths = []
|
||||
for i, path in enumerate(video_paths):
|
||||
normalized = os.path.join(self.work_dir, f"normalized_{i}_{os.getpid()}.mp4")
|
||||
self._normalize_video(path, normalized)
|
||||
normalized_paths.append(normalized)
|
||||
|
||||
durations = [self._get_video_info(p)["duration"] for p in normalized_paths]
|
||||
|
||||
if len(normalized_paths) <= 5:
|
||||
output_path = self._one_take_with_xfade(normalized_paths, durations, output_path)
|
||||
else:
|
||||
output_path = self._one_take_simple_concat(normalized_paths, output_path)
|
||||
|
||||
for p in normalized_paths:
|
||||
try:
|
||||
if p != output_path:
|
||||
os.remove(p)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
def _one_take_with_xfade(self, normalized_paths: list[str], durations: list[float], output_path: str) -> str:
|
||||
"""使用 xfade 滤镜实现转场"""
|
||||
if len(normalized_paths) == 2:
|
||||
transition = self.config.transition_duration
|
||||
offset1 = durations[0] - transition / 2
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
normalized_paths[0],
|
||||
"-i",
|
||||
normalized_paths[1],
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]xfade=transition=fade:duration={transition}:offset={offset1}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return output_path
|
||||
else:
|
||||
return self._one_take_simple_concat(normalized_paths, output_path)
|
||||
|
||||
def _one_take_simple_concat(self, normalized_paths: list[str], output_path: str) -> str:
|
||||
"""使用 concat demuxer 简单拼接"""
|
||||
concat_file = os.path.join(self.work_dir, f"concat_list_{os.getpid()}.txt")
|
||||
with open(concat_file, "w") as f:
|
||||
for path in normalized_paths:
|
||||
f.write(f"file '{os.path.abspath(path)}'\n")
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
try:
|
||||
os.remove(concat_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
def _pip(self, video_paths: list[str], output_path: str) -> str:
|
||||
"""画中画模式:主视频全屏,后续视频叠加在角落"""
|
||||
if not video_paths:
|
||||
raise ValueError("No video paths provided")
|
||||
|
||||
main_video = video_paths[0]
|
||||
main_normalized = os.path.join(self.work_dir, f"main_{os.getpid()}.mp4")
|
||||
main_info = self._normalize_video(main_video, main_normalized)
|
||||
|
||||
if len(video_paths) == 1:
|
||||
os.rename(main_normalized, output_path)
|
||||
return output_path
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4")
|
||||
pip_info = self._get_video_info(video_paths[1])
|
||||
|
||||
if pip_info["duration"] > main_info["duration"]:
|
||||
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
temp_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = temp_pip
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
pip_normalized,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = pip_normalized
|
||||
|
||||
if main_info["duration"] > pip_info["duration"]:
|
||||
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = looped_pip
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
main_normalized,
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
for temp_file in [main_normalized, pip_normalized]:
|
||||
if temp_file and temp_file != output_path:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def _voice_over(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str:
|
||||
"""口播模式:背景画面 + 配音"""
|
||||
if not audio_path:
|
||||
raise ValueError("audio_path is required for VOICE_OVER mode")
|
||||
|
||||
if not video_paths:
|
||||
raise ValueError("No background video provided")
|
||||
|
||||
audio_info = self._get_video_info(audio_path)
|
||||
audio_duration = audio_info["duration"]
|
||||
|
||||
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
|
||||
bg_info = self._normalize_video(video_paths[0], bg_normalized)
|
||||
|
||||
if bg_info["duration"] < audio_duration:
|
||||
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = looped_bg
|
||||
elif bg_info["duration"] > audio_duration:
|
||||
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
temp_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = temp_bg
|
||||
|
||||
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-vf",
|
||||
f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
blurred_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
blurred_bg,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
for temp_file in [bg_normalized, blurred_bg]:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
def _voice_pip(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str:
|
||||
"""口播+画中画模式:口播视频在角落,其他视频作为背景"""
|
||||
if not video_paths:
|
||||
raise ValueError("No video paths provided")
|
||||
|
||||
if len(video_paths) == 1:
|
||||
return self._normalize_video(video_paths[0], output_path)
|
||||
|
||||
voice_video = video_paths[0]
|
||||
bg_video = video_paths[1] if len(video_paths) > 1 else video_paths[0]
|
||||
|
||||
voice_normalized = os.path.join(self.work_dir, f"voice_{os.getpid()}.mp4")
|
||||
voice_info = self._normalize_video(voice_video, voice_normalized)
|
||||
|
||||
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
|
||||
bg_info = self._normalize_video(bg_video, bg_normalized)
|
||||
|
||||
final_duration = min(voice_info["duration"], bg_info["duration"])
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
voice_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
voice_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
bg_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
if audio_path:
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"2:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
for temp_file in [voice_normalized, voice_adjusted, bg_normalized, bg_adjusted]:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def create_processor(mode: str, work_dir: Optional[str] = None, **kwargs) -> EditingModeProcessor:
|
||||
"""便捷工厂函数:创建剪辑模式处理器"""
|
||||
try:
|
||||
editing_mode = EditingMode(mode)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid editing mode: {mode}. Valid modes: {[m.value for m in EditingMode]}")
|
||||
|
||||
config = EditingModeConfig(
|
||||
mode=editing_mode,
|
||||
output_width=kwargs.get("output_width", 1280),
|
||||
output_height=kwargs.get("output_height", 720),
|
||||
output_fps=kwargs.get("output_fps", 25),
|
||||
pip_position=PIPPosition(kwargs.get("pip_position", "top_right")),
|
||||
pip_scale=kwargs.get("pip_scale", 0.25),
|
||||
transition_duration=kwargs.get("transition_duration", 0.5),
|
||||
)
|
||||
|
||||
return EditingModeProcessor(config=config, work_dir=work_dir)
|
||||
Executable
+396
@@ -0,0 +1,396 @@
|
||||
"""FFmpeg 工具函数 — 共享原语.
|
||||
|
||||
提供 FFmpeg / FFprobe 调用、视频信息探测、视频标准化、xfade 转场滤镜构建
|
||||
等底层能力,供 UnifiedRenderService、VideoComposeService 等复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
DEFAULT_FPS = 25
|
||||
|
||||
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
|
||||
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
|
||||
XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
"fade": "fade",
|
||||
"slideleft": "slideleft",
|
||||
"slide_left": "slideleft",
|
||||
"slideright": "slideright",
|
||||
"slide_right": "slideright",
|
||||
"slideup": "slideup",
|
||||
"slide_up": "slideup",
|
||||
"slidedown": "slidedown",
|
||||
"slide_down": "slidedown",
|
||||
"dissolve": "dissolve",
|
||||
"wipe": "wipeleft",
|
||||
"wipeleft": "wipeleft",
|
||||
}
|
||||
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
|
||||
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
|
||||
DEFAULT_FFMPEG_TIMEOUT = 1800
|
||||
|
||||
|
||||
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_ffmpeg(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool = True,
|
||||
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
|
||||
) -> tuple[str, str]:
|
||||
"""执行 FFmpeg 命令。
|
||||
|
||||
Args:
|
||||
command: 完整的 ffmpeg 命令列表(含 "ffmpeg" 本身)
|
||||
capture_output: 是否捕获 stdout/stderr
|
||||
timeout: 超时时间(秒),默认 1800s(30分钟);None 表示不设超时(不推荐)
|
||||
|
||||
Returns:
|
||||
(stdout, stderr) 元组
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出,
|
||||
异常信息包含完整 stderr 以便排查。
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出,FFmpeg 进程会被 kill。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
logger.error(
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
)
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
|
||||
stderr_text = (e.stderr or "").strip()
|
||||
logger.error(
|
||||
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
e.returncode,
|
||||
" ".join(str(c) for c in command[:20]), # 截断过长的命令
|
||||
stderr_text[:5000], # 截断过长的 stderr
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def probe_has_audio(local_path: str | Path) -> bool:
|
||||
"""探测文件是否包含音频流。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径
|
||||
|
||||
Returns:
|
||||
True 表示有音频流(或探测失败保守返回),False 表示确认无音频流
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_type",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.stdout.strip() == "audio"
|
||||
except Exception:
|
||||
# 探测失败保守返回 True,让 FFmpeg 自己处理(避免误删音频)
|
||||
return True
|
||||
|
||||
|
||||
def probe_duration(local_path: str | Path) -> float:
|
||||
"""用 ffprobe 获取视频时长(秒)。
|
||||
|
||||
失败时返回默认值 5.0 秒。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return round(float(result.stdout.strip()), 3)
|
||||
except Exception:
|
||||
return 5.0
|
||||
|
||||
|
||||
def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
"""获取视频信息(宽、高、时长、fps、编码、像素格式)。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"width": int, "height": int, "duration": float, "fps": float,
|
||||
"video_codec": str, "audio_codec": str, "pix_fmt": str,
|
||||
"has_audio": bool,
|
||||
}
|
||||
失败时返回默认值。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration,codec_name,codec_type,pix_fmt",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
import json
|
||||
|
||||
info = json.loads(result.stdout)
|
||||
streams = info.get("streams", [])
|
||||
fmt = info.get("format", {})
|
||||
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
|
||||
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {})
|
||||
|
||||
width = int(video_stream.get("width", DEFAULT_OUTPUT_WIDTH))
|
||||
height = int(video_stream.get("height", DEFAULT_OUTPUT_HEIGHT))
|
||||
video_codec = video_stream.get("codec_name", "") or ""
|
||||
pix_fmt = video_stream.get("pix_fmt", "") or ""
|
||||
|
||||
# 解析帧率
|
||||
fps_str = video_stream.get("r_frame_rate", "25/1")
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) > 0 else DEFAULT_FPS
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else DEFAULT_FPS
|
||||
|
||||
# 时长
|
||||
duration = float(fmt.get("duration", 0)) or float(video_stream.get("duration", 0))
|
||||
|
||||
has_audio = bool(audio_stream)
|
||||
audio_codec = audio_stream.get("codec_name", "") or ""
|
||||
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": duration,
|
||||
"fps": round(fps, 2),
|
||||
"video_codec": video_codec,
|
||||
"audio_codec": audio_codec,
|
||||
"pix_fmt": pix_fmt,
|
||||
"has_audio": has_audio,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("获取视频信息失败: %s, error: %s", video_path, e)
|
||||
return {
|
||||
"width": DEFAULT_OUTPUT_WIDTH,
|
||||
"height": DEFAULT_OUTPUT_HEIGHT,
|
||||
"duration": 0.0,
|
||||
"fps": DEFAULT_FPS,
|
||||
"video_codec": "",
|
||||
"audio_codec": "",
|
||||
"pix_fmt": "",
|
||||
"has_audio": True,
|
||||
}
|
||||
|
||||
|
||||
def normalize_video(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
*,
|
||||
width: int = DEFAULT_OUTPUT_WIDTH,
|
||||
height: int = DEFAULT_OUTPUT_HEIGHT,
|
||||
fps: int = DEFAULT_FPS,
|
||||
) -> dict[str, Any]:
|
||||
"""标准化视频(缩放 + 恒定帧率)。
|
||||
|
||||
使用 scale + pad 保持宽高比,黑边填充到目标分辨率。
|
||||
|
||||
Returns:
|
||||
{"width": int, "height": int, "path": str}
|
||||
"""
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-vf",
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||
f"fps={fps}",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"23",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(command)
|
||||
return {"width": width, "height": height, "path": output_path}
|
||||
|
||||
|
||||
# ── xfade / concat 滤镜构建 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
|
||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
|
||||
|
||||
例:chain_filters(["scale=1280:720", "fps=25"], "v0")
|
||||
→ "[0:v]scale=1280:720,fps=25[v0]"
|
||||
"""
|
||||
filter_body = ",".join(filters)
|
||||
return f"[{input_label}]{filter_body}[{output_label}]"
|
||||
|
||||
|
||||
def resolve_xfade_transition(transition_name: str) -> str:
|
||||
"""将转场效果名称映射为 FFmpeg xfade transition 名称。
|
||||
|
||||
支持 TransitionEffect 枚举值和字符串名称,未知值回退到 "fade"。
|
||||
"""
|
||||
# 兼容 TransitionEffect 枚举(有 .value 属性)
|
||||
if hasattr(transition_name, "value"):
|
||||
transition_name = transition_name.value
|
||||
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
|
||||
|
||||
|
||||
def build_xfade_filter_chain(
|
||||
clip_durations: list[float],
|
||||
clip_video_labels: list[str],
|
||||
transitions: list[str],
|
||||
*,
|
||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||
output_label: str = "outv",
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链。
|
||||
|
||||
对每步 xfade 自动钳制 transition duration,确保
|
||||
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
|
||||
|
||||
Args:
|
||||
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
|
||||
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1")
|
||||
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
|
||||
transition_duration: 转场时长(秒)
|
||||
output_label: 最终输出标签
|
||||
|
||||
Returns:
|
||||
(filter_string, estimated_total_duration)
|
||||
"""
|
||||
n = len(clip_durations)
|
||||
parts: list[str] = []
|
||||
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
if n == 1:
|
||||
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
|
||||
return ";".join(parts), clip_durations[0]
|
||||
|
||||
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
|
||||
cumulative = 0.0
|
||||
prev_label = clip_video_labels[0]
|
||||
total_transition = 0.0 # 累计已使用的转场时长
|
||||
|
||||
for i in range(1, n):
|
||||
cumulative += clip_durations[i - 1]
|
||||
|
||||
# 当前 xfade 的第一个输入时长
|
||||
if i == 1:
|
||||
first_input_dur = clip_durations[0]
|
||||
else:
|
||||
first_input_dur = cumulative - total_transition
|
||||
|
||||
# 原始 offset 计算
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
|
||||
# 安全钳制:offset + td 不能超过第一个输入的时长
|
||||
available = max(0.0, first_input_dur - offset)
|
||||
safe_td = min(transition_duration, available)
|
||||
|
||||
# 同时不能超过剩余总时长
|
||||
remaining = max(0.0, sum(clip_durations) - cumulative)
|
||||
safe_td = min(safe_td, remaining)
|
||||
# 同时不能超过当前第二个输入(单个片段)的时长
|
||||
safe_td = min(safe_td, clip_durations[i])
|
||||
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
|
||||
|
||||
transition = transitions[i] if i < len(transitions) else "cut"
|
||||
xfade_transition = resolve_xfade_transition(transition)
|
||||
|
||||
if i == n - 1:
|
||||
out_label = output_label
|
||||
else:
|
||||
out_label = f"xf{i}"
|
||||
|
||||
parts.append(
|
||||
f"[{prev_label}][{clip_video_labels[i]}]"
|
||||
f"xfade=transition={xfade_transition}"
|
||||
f":duration={safe_td:.3f}"
|
||||
f":offset={offset:.3f}"
|
||||
f"[{out_label}]"
|
||||
)
|
||||
prev_label = out_label
|
||||
total_transition += safe_td
|
||||
|
||||
# 总时长减去转场重叠部分
|
||||
total_duration = sum(clip_durations) - total_transition
|
||||
return ";".join(parts), max(0.0, total_duration)
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
"""OSS 工具函数 — 从 generation.py / edit_plan_generation.py 提取的共享 OSS 操作.
|
||||
|
||||
提供 OSS 配置读取、Bucket 创建、素材上传/下载、asset_id → 本地路径解析
|
||||
等能力,供 render_edit_plan 和 generate_video 共同复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import oss2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OSS 上传配置
|
||||
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
|
||||
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
|
||||
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
|
||||
|
||||
|
||||
# ── OSS 配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def oss_settings() -> tuple[str, str, str, str] | None:
|
||||
"""获取 OSS 配置。
|
||||
|
||||
Returns:
|
||||
(access_key_id, access_key_secret, endpoint, bucket_name) 元组,
|
||||
配置缺失时返回 None。
|
||||
"""
|
||||
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
|
||||
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
|
||||
endpoint = os.getenv("OSS_ENDPOINT")
|
||||
bucket_name = os.getenv("OSS_BUCKET_NAME")
|
||||
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
|
||||
return None
|
||||
return access_key_id, access_key_secret, endpoint, bucket_name
|
||||
|
||||
|
||||
def oss_bucket() -> oss2.Bucket | None:
|
||||
"""获取 OSS Bucket 实例。
|
||||
|
||||
P0-2 修复:endpoint 不带 scheme 时自动补 https:// 前缀,
|
||||
确保 sign_url 等依赖 scheme 的方法返回 HTTPS URL。
|
||||
|
||||
P0-staging 修复:增加 connect_timeout=10s,防止网络抖动时
|
||||
TCP 握手阶段无限挂死,导致 worker 进程卡死。
|
||||
|
||||
Returns:
|
||||
oss2.Bucket 实例,配置缺失时返回 None。
|
||||
"""
|
||||
settings = oss_settings()
|
||||
if settings is None:
|
||||
return None
|
||||
access_key_id, access_key_secret, endpoint, bucket_name = settings
|
||||
# endpoint 无 scheme 时补 https://,与 API 端 storage.py 保持一致
|
||||
if not endpoint.startswith(("http://", "https://")):
|
||||
endpoint = f"https://{endpoint}"
|
||||
return oss2.Bucket(
|
||||
oss2.Auth(access_key_id, access_key_secret),
|
||||
endpoint,
|
||||
bucket_name,
|
||||
connect_timeout=OSS_CONNECT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def normalize_storage_key(storage_key_or_url: str) -> str:
|
||||
"""标准化存储键 — 如果是完整 URL 则提取 path 部分。
|
||||
|
||||
Examples:
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
|
||||
→ "path/to/file.mp4"
|
||||
"path/to/file.mp4" → "path/to/file.mp4"
|
||||
"""
|
||||
if storage_key_or_url.startswith(("http://", "https://")):
|
||||
return urlparse(storage_key_or_url).path.lstrip("/")
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
|
||||
# ── 上传 / 下载 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
|
||||
"""从 OSS 下载素材文件到本地路径。
|
||||
|
||||
Args:
|
||||
asset_storage_key: 素材的存储键(或完整 URL)
|
||||
local_path: 本地保存路径
|
||||
|
||||
Returns:
|
||||
True 表示下载成功,False 表示失败。
|
||||
"""
|
||||
bucket = oss_bucket()
|
||||
if bucket is None:
|
||||
return False
|
||||
try:
|
||||
bucket.get_object_to_file(normalize_storage_key(asset_storage_key), str(local_path))
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("下载素材失败: %s", asset_storage_key)
|
||||
return False
|
||||
|
||||
|
||||
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径
|
||||
storage_key: 目标存储键
|
||||
|
||||
Returns:
|
||||
公开访问 URL,上传失败或 OSS 未配置时返回 None。
|
||||
"""
|
||||
bucket = oss_bucket()
|
||||
if bucket is None:
|
||||
return None
|
||||
|
||||
result: dict = {"url": None, "error": None, "file_size": 0}
|
||||
done = threading.Event()
|
||||
|
||||
def _do_upload():
|
||||
try:
|
||||
# 尝试获取文件大小,用于分片判断和日志;stat 失败时 fallback 走普通上传
|
||||
try:
|
||||
file_size = local_path.stat().st_size
|
||||
result["file_size"] = file_size
|
||||
use_multipart = file_size >= OSS_MULTIPART_THRESHOLD
|
||||
except OSError:
|
||||
use_multipart = False
|
||||
file_size = 0
|
||||
|
||||
if use_multipart:
|
||||
# 分片上传:降低内存峰值,每片 8MB,3 线程并发
|
||||
logger.info(
|
||||
"大文件分片上传: storage_key=%s, size=%.1fMB, part_size=%dMB, threads=%d",
|
||||
storage_key[:80],
|
||||
file_size / 1024 / 1024,
|
||||
OSS_PART_SIZE // 1024 // 1024,
|
||||
OSS_MULTIPART_NUM_THREADS,
|
||||
)
|
||||
oss2.resumable_upload(
|
||||
bucket,
|
||||
storage_key,
|
||||
str(local_path),
|
||||
multipart_threshold=OSS_MULTIPART_THRESHOLD,
|
||||
part_size=OSS_PART_SIZE,
|
||||
num_threads=OSS_MULTIPART_NUM_THREADS,
|
||||
)
|
||||
else:
|
||||
bucket.put_object_from_file(storage_key, str(local_path))
|
||||
|
||||
# 构造返回 URL
|
||||
settings = oss_settings()
|
||||
if settings:
|
||||
_, _, endpoint, bucket_name = settings
|
||||
endpoint_clean = endpoint.replace("https://", "").replace("http://", "")
|
||||
result["url"] = f"https://{bucket_name}.{endpoint_clean}/{storage_key}"
|
||||
except Exception as e:
|
||||
result["error"] = e
|
||||
logger.exception("上传 OSS 失败: %s", storage_key)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
upload_thread = threading.Thread(target=_do_upload, daemon=True)
|
||||
upload_thread.start()
|
||||
finished = done.wait(timeout=OSS_UPLOAD_TOTAL_TIMEOUT)
|
||||
|
||||
if not finished:
|
||||
logger.error(
|
||||
"OSS 上传超时(%.0fs),强制中止: storage_key=%s, size=%.1fMB",
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT,
|
||||
storage_key[:80],
|
||||
result["file_size"] / 1024 / 1024 if result["file_size"] else 0,
|
||||
)
|
||||
return None
|
||||
|
||||
if result["error"]:
|
||||
return None
|
||||
|
||||
return result["url"]
|
||||
|
||||
|
||||
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | None:
|
||||
"""生成预签名下载 URL(用于私有 bucket 的 URL 校验或临时下载)。
|
||||
|
||||
Args:
|
||||
storage_key_or_url: 存储键或完整 URL(URL 会自动提取 path)
|
||||
expires_seconds: 签名有效期(秒)
|
||||
|
||||
Returns:
|
||||
预签名 URL,失败或 OSS 未配置时返回 None。
|
||||
"""
|
||||
bucket = oss_bucket()
|
||||
if bucket is None:
|
||||
return None
|
||||
try:
|
||||
storage_key = normalize_storage_key(storage_key_or_url)
|
||||
signed = bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info("生成预签名URL: key=%s url_prefix=%s", storage_key[:80], signed[:60])
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
|
||||
return None
|
||||
|
||||
|
||||
# ── Asset 解析 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略(按优先级):
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 直接返回
|
||||
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
|
||||
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
|
||||
4. 下载失败 → 返回 None
|
||||
|
||||
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
|
||||
"""
|
||||
# 1. 本地绝对路径
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
return Path(asset_id)
|
||||
|
||||
# 2. 缓存命中
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
cached_path = work_dir / f"{cache_hash}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载
|
||||
if download_asset(asset_id, cached_path):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_asset_ids_to_paths(
|
||||
asset_ids: list[str],
|
||||
work_dir: Path,
|
||||
) -> dict[str, Path]:
|
||||
"""批量解析 asset_id → 本地路径。
|
||||
|
||||
Args:
|
||||
asset_ids: 素材 ID 列表
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
{asset_id: local_path} 映射,仅包含成功解析的条目。
|
||||
"""
|
||||
result: dict[str, Path] = {}
|
||||
for aid in asset_ids:
|
||||
local_path = resolve_asset_path(aid, work_dir)
|
||||
if local_path:
|
||||
result[aid] = local_path
|
||||
return result
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
"""统一渲染引擎适配层 — Phase 2.
|
||||
|
||||
将 EditPlan + EditPlanClips(来自 DB)适配为 UnifiedRenderService 的输入格式,
|
||||
封装素材下载、渲染执行、结果上传的完整流程。
|
||||
|
||||
职责:
|
||||
1. 从 DB 读取 EditPlan + EditPlanClips
|
||||
2. 下载素材到本地,构建 asset_path_map
|
||||
3. 调用 UnifiedRenderService 执行渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 支持进度回调(对接 JobService)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from video_processing.oss_helpers import download_asset, upload_to_oss
|
||||
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderAdapterResult:
|
||||
"""渲染适配结果。"""
|
||||
|
||||
success: bool
|
||||
output_url: str = ""
|
||||
output_path: Path | None = None
|
||||
duration: float = 0.0
|
||||
file_size: int = 0
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
clip_count: int = 0
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
ProgressCallback = Callable[[float, str], None]
|
||||
"""进度回调:(progress_0_100, stage_description) → None"""
|
||||
|
||||
|
||||
# ── 适配层主体 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RenderAdapter:
|
||||
"""统一渲染引擎适配层。
|
||||
|
||||
桥接 EditPlan 领域模型与 UnifiedRenderService 图层模型。
|
||||
|
||||
用法::
|
||||
|
||||
adapter = RenderAdapter(db)
|
||||
result = adapter.render_plan(
|
||||
plan_id=plan_id,
|
||||
job_id=job_id,
|
||||
progress_cb=lambda p, s: job_service.update_progress(job_id, p, s),
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._db = db
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
|
||||
# ── 公开方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
def render_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
) -> RenderAdapterResult:
|
||||
"""渲染一个 EditPlan。
|
||||
|
||||
完整流程:
|
||||
1. 加载计划与片段
|
||||
2. 下载素材
|
||||
3. 执行统一渲染
|
||||
4. 上传结果
|
||||
|
||||
Args:
|
||||
plan_id: EditPlan ID
|
||||
job_id: 关联的 Job ID(用于结果存储路径)
|
||||
work_dir: 工作目录,不传则使用临时目录
|
||||
progress_cb: 进度回调函数
|
||||
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
"""
|
||||
temp_dir = None
|
||||
try:
|
||||
# 0. 准备工作目录
|
||||
if work_dir is None:
|
||||
temp_dir = tempfile.mkdtemp(prefix="render_")
|
||||
work_dir = Path(temp_dir)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._report_progress(progress_cb, 5.0, "加载剪辑计划")
|
||||
|
||||
# 1. 加载计划与片段
|
||||
plan = self._plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id]
|
||||
ready_clips.sort(key=lambda c: c.order)
|
||||
|
||||
if not ready_clips:
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message="没有可渲染的就绪片段",
|
||||
clip_count=0,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"开始渲染: plan_id=%s job_id=%s ready_clips=%d engine=unified",
|
||||
plan_id,
|
||||
job_id,
|
||||
len(ready_clips),
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 15.0, f"下载素材({len(ready_clips)} 个)")
|
||||
|
||||
# 2. 下载素材
|
||||
asset_path_map = self._download_assets(ready_clips, work_dir)
|
||||
if not asset_path_map:
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message="所有素材下载失败",
|
||||
clip_count=len(ready_clips),
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
# 3. 执行统一渲染
|
||||
render_svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=ready_clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
self._report_progress(progress_cb, 80.0, "上传渲染结果")
|
||||
|
||||
# 4. 上传结果
|
||||
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
|
||||
output_url = upload_to_oss(result.output_path, storage_key)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
logger.info(
|
||||
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
|
||||
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
|
||||
plan_id,
|
||||
job_id,
|
||||
result.duration,
|
||||
result.file_size,
|
||||
result.width,
|
||||
result.height,
|
||||
len(ready_clips),
|
||||
)
|
||||
|
||||
return RenderAdapterResult(
|
||||
success=True,
|
||||
output_url=output_url or "",
|
||||
output_path=result.output_path,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
clip_count=len(ready_clips),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"[render-adapter] render failed: plan_id=%s job_id=%s engine=unified error=%s",
|
||||
plan_id,
|
||||
job_id,
|
||||
str(exc)[:200],
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=str(exc)[:500],
|
||||
)
|
||||
finally:
|
||||
# 清理临时目录
|
||||
if temp_dir:
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def validate_plan(self, plan_id: str) -> tuple[bool, list[str], list[str], int, int]:
|
||||
"""校验计划是否可渲染(兼容 VideoComposeService.validate_compose 接口)。
|
||||
|
||||
Returns:
|
||||
(valid, errors, warnings, ready_clip_count, total_clip_count)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
plan = self._plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
return False, [f"剪辑计划不存在: {plan_id}"], [], 0, 0
|
||||
|
||||
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
|
||||
errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status}")
|
||||
|
||||
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
if not clips:
|
||||
errors.append("计划没有任何片段")
|
||||
return False, errors, warnings, 0, 0
|
||||
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
ready_count = 0
|
||||
pending_count = 0
|
||||
no_asset_count = 0
|
||||
|
||||
for clip in clips:
|
||||
if clip.status == EditPlanClipStatus.READY:
|
||||
ready_count += 1
|
||||
if not clip.asset_id:
|
||||
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
|
||||
no_asset_count += 1
|
||||
elif clip.status == EditPlanClipStatus.PENDING:
|
||||
pending_count += 1
|
||||
elif clip.status == EditPlanClipStatus.FAILED:
|
||||
warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过")
|
||||
|
||||
if ready_count == 0:
|
||||
errors.append("没有就绪(ready)的片段可以合成")
|
||||
|
||||
if pending_count > 0:
|
||||
warnings.append(f"有 {pending_count} 个片段仍处于 pending 状态")
|
||||
|
||||
return len(errors) == 0, errors, warnings, ready_count, len(clips)
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _report_progress(progress_cb: ProgressCallback | None, progress: float, stage: str) -> None:
|
||||
"""上报进度。"""
|
||||
if progress_cb is not None:
|
||||
try:
|
||||
progress_cb(progress, stage)
|
||||
except Exception:
|
||||
logger.exception("进度回调失败")
|
||||
|
||||
@staticmethod
|
||||
def _download_assets(clips: list[EditPlanClip], work_dir: Path) -> dict[str, Path]:
|
||||
"""下载片段素材到本地,返回 asset_id → local_path 映射。
|
||||
|
||||
只保留下载成功的素材。
|
||||
"""
|
||||
asset_dir = work_dir / "assets"
|
||||
asset_dir.mkdir(exist_ok=True)
|
||||
|
||||
asset_path_map: dict[str, Path] = {}
|
||||
|
||||
for clip in clips:
|
||||
asset_id = clip.asset_id
|
||||
if not asset_id:
|
||||
continue
|
||||
|
||||
# 生成安全的本地文件名
|
||||
safe_name = f"clip_{clip.order:04d}_{abs(hash(asset_id)) % 100000:05d}.mp4"
|
||||
local_path = asset_dir / safe_name
|
||||
|
||||
if download_asset(asset_id, local_path):
|
||||
asset_path_map[asset_id] = local_path
|
||||
logger.debug("素材下载成功: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
|
||||
else:
|
||||
logger.warning("素材下载失败: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
|
||||
|
||||
return asset_path_map
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"""渲染引擎 Feature Flag 解析器。
|
||||
|
||||
封装渲染引擎选择逻辑,支持:
|
||||
- 环境变量作为默认值(RENDER_ENGINE=legacy/unified)
|
||||
- Redis Feature Flag 运行时覆盖(白名单 + 百分比 + 全局开关)
|
||||
- 定时刷新,支持热更新不重启 worker
|
||||
|
||||
使用方式:
|
||||
resolver = RenderEngineResolver(redis_url="redis://...", default_engine="legacy")
|
||||
engine = resolver.get_engine(user_id="user123")
|
||||
# engine: "legacy" 或 "unified"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
FeatureFlagStore,
|
||||
InMemoryFeatureFlagStore,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature Flag 名称常量
|
||||
FLAG_RENDER_ENGINE = "render_engine"
|
||||
|
||||
# 引擎常量
|
||||
ENGINE_LEGACY = "legacy"
|
||||
ENGINE_UNIFIED = "unified"
|
||||
VALID_ENGINES = {ENGINE_LEGACY, ENGINE_UNIFIED}
|
||||
|
||||
|
||||
class RenderEngineResolver:
|
||||
"""渲染引擎选择器。
|
||||
|
||||
判定逻辑(从高到低):
|
||||
1. Redis flag 白名单匹配 → unified
|
||||
2. Redis flag 百分比命中 → unified
|
||||
3. Redis flag 全局开启(100%)→ unified
|
||||
4. 环境变量默认值 → legacy / unified
|
||||
|
||||
当 Redis 不可用时,自动降级到环境变量默认值,不影响业务。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_engine: str = ENGINE_LEGACY,
|
||||
redis_url: Optional[str] = None,
|
||||
refresh_interval: float = 30.0,
|
||||
store: Optional[FeatureFlagStore] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
default_engine: 环境变量默认的引擎名(legacy / unified)
|
||||
redis_url: Redis 连接 URL,传 None 时使用内存实现(测试用)
|
||||
refresh_interval: Redis flag 配置刷新间隔(秒)
|
||||
store: 直接传入 store 实例(测试用,优先级高于 redis_url)
|
||||
"""
|
||||
self._default_engine = default_engine.lower() if default_engine else ENGINE_LEGACY
|
||||
if self._default_engine not in VALID_ENGINES:
|
||||
logger.warning(
|
||||
"Invalid default engine '%s', fallback to '%s'",
|
||||
self._default_engine,
|
||||
ENGINE_LEGACY,
|
||||
)
|
||||
self._default_engine = ENGINE_LEGACY
|
||||
|
||||
if store is not None:
|
||||
self._store = store
|
||||
elif redis_url:
|
||||
self._store = RedisFeatureFlagStore(redis_url=redis_url)
|
||||
else:
|
||||
self._store = InMemoryFeatureFlagStore()
|
||||
logger.info("No Redis configured, using in-memory feature flag store")
|
||||
|
||||
self._refresh_interval = refresh_interval
|
||||
self._lock = threading.Lock()
|
||||
self._cached_config: Optional[FeatureFlagConfig] = None
|
||||
self._last_refresh: float = 0.0
|
||||
|
||||
def _maybe_refresh(self) -> None:
|
||||
"""惰性刷新配置,超过刷新间隔时从存储重新读取。"""
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_refresh < self._refresh_interval:
|
||||
return
|
||||
|
||||
try:
|
||||
config = self._store.get(FLAG_RENDER_ENGINE)
|
||||
with self._lock:
|
||||
self._cached_config = config
|
||||
self._last_refresh = now
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to refresh render engine flag: %s", exc)
|
||||
# 刷新失败时保留旧缓存,不中断业务
|
||||
if self._cached_config is None:
|
||||
# 首次就读失败,设一个默认值
|
||||
with self._lock:
|
||||
self._cached_config = FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
|
||||
self._last_refresh = now
|
||||
|
||||
def _get_config(self) -> FeatureFlagConfig:
|
||||
"""获取当前 flag 配置(带缓存)。"""
|
||||
if self._cached_config is None:
|
||||
self._maybe_refresh()
|
||||
else:
|
||||
self._maybe_refresh()
|
||||
return self._cached_config or FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
|
||||
|
||||
def get_engine(self, user_id: Optional[str] = None) -> str:
|
||||
"""获取当前应该使用的渲染引擎。
|
||||
|
||||
Args:
|
||||
user_id: 用户ID,用于白名单匹配和百分比哈希。
|
||||
传 None 时只看全局开关。
|
||||
|
||||
Returns:
|
||||
"legacy" 或 "unified"
|
||||
"""
|
||||
config = self._get_config()
|
||||
|
||||
# 全局关闭 → 用默认值
|
||||
if not config.enabled:
|
||||
return self._default_engine
|
||||
|
||||
# 白名单匹配 / 百分比命中 → unified
|
||||
if config.is_active(user_id):
|
||||
return ENGINE_UNIFIED
|
||||
|
||||
# 未命中灰度 → 用默认值
|
||||
return self._default_engine
|
||||
|
||||
def should_use_unified(self, user_id: Optional[str] = None) -> bool:
|
||||
"""便捷方法:是否应该使用统一渲染引擎。"""
|
||||
return self.get_engine(user_id) == ENGINE_UNIFIED
|
||||
|
||||
def force_refresh(self) -> None:
|
||||
"""强制立即刷新配置(用于管理接口修改后立即生效)。"""
|
||||
self._last_refresh = 0.0
|
||||
if isinstance(self._store, RedisFeatureFlagStore):
|
||||
self._store.invalidate_cache(FLAG_RENDER_ENGINE)
|
||||
self._maybe_refresh()
|
||||
|
||||
def get_config_snapshot(self) -> dict:
|
||||
"""获取当前配置快照(用于管理接口展示)。"""
|
||||
config = self._get_config()
|
||||
return {
|
||||
"flag_name": FLAG_RENDER_ENGINE,
|
||||
"default_engine": self._default_engine,
|
||||
"enabled": config.enabled,
|
||||
"percentage": config.percentage,
|
||||
"whitelist": sorted(config.whitelist),
|
||||
"refresh_interval": self._refresh_interval,
|
||||
"last_refresh": self._last_refresh,
|
||||
}
|
||||
|
||||
def set_flag(self, config: FeatureFlagConfig) -> None:
|
||||
"""设置 flag 配置(管理接口用)。"""
|
||||
config.name = FLAG_RENDER_ENGINE
|
||||
self._store.set(config)
|
||||
self.force_refresh()
|
||||
|
||||
|
||||
# 全局单例
|
||||
_resolver: Optional[RenderEngineResolver] = None
|
||||
_resolver_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_render_engine_resolver() -> RenderEngineResolver:
|
||||
"""获取全局单例(基于 worker 配置)。"""
|
||||
global _resolver
|
||||
if _resolver is not None:
|
||||
return _resolver
|
||||
|
||||
with _resolver_lock:
|
||||
if _resolver is not None:
|
||||
return _resolver
|
||||
|
||||
try:
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
redis_url = getattr(settings, "redis_url", None) or getattr(settings, "broker_url", None)
|
||||
default = getattr(settings, "render_engine", ENGINE_LEGACY)
|
||||
_resolver = RenderEngineResolver(
|
||||
default_engine=default,
|
||||
redis_url=redis_url,
|
||||
)
|
||||
logger.info(
|
||||
"RenderEngineResolver initialized: default=%s, redis=%s",
|
||||
default,
|
||||
bool(redis_url),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to init RenderEngineResolver from settings: %s", exc)
|
||||
_resolver = RenderEngineResolver(default_engine=ENGINE_LEGACY)
|
||||
|
||||
return _resolver
|
||||
+1510
File diff suppressed because it is too large
Load Diff
@@ -1,821 +0,0 @@
|
||||
"""
|
||||
视频合成服务
|
||||
支持多种剪辑模式和转场效果,包含完整的安全校验
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
try:
|
||||
from enum import StrEnum
|
||||
except ImportError:
|
||||
|
||||
class StrEnum(str, Enum): # type: ignore[no-redef]
|
||||
"""Python 3.10 兼容的 StrEnum 回退实现。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ========== 安全常量 ==========
|
||||
# 允许的输出目录白名单(使用环境变量或系统临时目录,避免硬编码 /tmp)
|
||||
_VIDEO_OUTPUT_DIR = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
ALLOWED_OUTPUT_DIRS = [_VIDEO_OUTPUT_DIR, "/var/app/rendered"]
|
||||
|
||||
# 允许的输入路径前缀白名单
|
||||
ALLOWED_INPUT_PREFIXES = ("s3://", "oss://", "local://", "/var/storage/")
|
||||
|
||||
# 允许的转场效果白名单
|
||||
ALLOWED_TRANSITIONS = {
|
||||
"fade",
|
||||
"slideleft",
|
||||
"slideright",
|
||||
"dissolve",
|
||||
"wipeleft",
|
||||
"wiperight",
|
||||
"cut",
|
||||
"slideup",
|
||||
"slidedown",
|
||||
}
|
||||
|
||||
# 转场效果映射
|
||||
_XFADE_TRANSITION_MAP = {
|
||||
"fade": "fade",
|
||||
"slideleft": "slideleft",
|
||||
"slideright": "slideright",
|
||||
"dissolve": "dissolve",
|
||||
"wipeleft": "wipeleft",
|
||||
"wiperight": "wiperight",
|
||||
"cut": "cut",
|
||||
"slideup": "slideup",
|
||||
"slidedown": "slidedown",
|
||||
}
|
||||
|
||||
|
||||
class VideoComposeError(Exception):
|
||||
"""视频合成服务异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PIPPosition(StrEnum):
|
||||
"""画中画位置枚举"""
|
||||
|
||||
TOP_LEFT = "top_left"
|
||||
TOP_RIGHT = "top_right"
|
||||
BOTTOM_LEFT = "bottom_left"
|
||||
BOTTOM_RIGHT = "bottom_right"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Clip:
|
||||
"""视频片段"""
|
||||
|
||||
asset_id: str # 资源ID,对应输入路径
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition: str = "fade" # 转场效果
|
||||
|
||||
|
||||
@dataclass
|
||||
class EditingModeConfig:
|
||||
"""剪辑模式配置"""
|
||||
|
||||
mode: EditingMode
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
output_fps: int = 25
|
||||
pip_position: PIPPosition = PIPPosition.TOP_RIGHT
|
||||
pip_scale: float = 0.25 # 画中画占主画面的比例
|
||||
transition_duration: float = 0.5 # 转场时长(秒)
|
||||
output_codec: str = "libx264"
|
||||
output_preset: str = "medium"
|
||||
output_crf: int = 23
|
||||
|
||||
|
||||
class VideoComposeService:
|
||||
"""视频合成服务"""
|
||||
|
||||
def __init__(self, config: EditingModeConfig, work_dir: Optional[str] = None):
|
||||
"""
|
||||
初始化视频合成服务
|
||||
|
||||
Args:
|
||||
config: 剪辑模式配置
|
||||
work_dir: 工作目录,默认使用系统临时目录
|
||||
"""
|
||||
self.config = config
|
||||
self.work_dir = work_dir or tempfile.gettempdir()
|
||||
self._ffmpeg_bin = "ffmpeg"
|
||||
self._ffprobe_bin = "ffprobe"
|
||||
|
||||
def _validate_output_path(self, path: str) -> str:
|
||||
"""
|
||||
校验输出路径是否在允许范围内 (P0 修复)
|
||||
|
||||
防止路径穿越攻击,如 /app/config/../../../etc/passwd
|
||||
|
||||
Args:
|
||||
path: 用户提供的输出路径
|
||||
|
||||
Returns:
|
||||
标准化后的绝对路径
|
||||
|
||||
Raises:
|
||||
ValueError: 路径不在允许范围内
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
for allowed_dir in ALLOWED_OUTPUT_DIRS:
|
||||
allowed_abs = os.path.abspath(allowed_dir)
|
||||
if abs_path.startswith(allowed_abs):
|
||||
return abs_path
|
||||
raise ValueError(f"输出路径不在允许范围内: {path}")
|
||||
|
||||
def _validate_input_path(self, path: str) -> bool:
|
||||
"""
|
||||
校验输入路径格式是否合法 (P1-1 修复)
|
||||
|
||||
Args:
|
||||
path: 输入文件路径
|
||||
|
||||
Returns:
|
||||
是否合法
|
||||
"""
|
||||
return any(path.startswith(prefix) for prefix in ALLOWED_INPUT_PREFIXES)
|
||||
|
||||
def _validate_transition(self, transition: str) -> str:
|
||||
"""
|
||||
校验转场效果是否在白名单内 (P1-2 修复)
|
||||
|
||||
Args:
|
||||
transition: 转场效果名称
|
||||
|
||||
Returns:
|
||||
安全的转场效果名称
|
||||
"""
|
||||
if transition not in ALLOWED_TRANSITIONS:
|
||||
logger.warning(f"未知的转场效果 '{transition}',使用默认 'fade'")
|
||||
return "fade"
|
||||
return transition
|
||||
|
||||
def _get_validated_transition(self, transition: str) -> str:
|
||||
"""获取白名单校验后的转场效果名称"""
|
||||
return _XFADE_TRANSITION_MAP.get(self._validate_transition(transition), "fade")
|
||||
|
||||
def compose(self, clips: list[Clip], output_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
合成视频
|
||||
|
||||
Args:
|
||||
clips: 视频片段列表,每个片段包含 asset_id 和转场配置
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if not clips:
|
||||
raise ValueError("clips 不能为空")
|
||||
|
||||
# P1-1: 校验所有输入路径
|
||||
for clip in clips:
|
||||
if not self._validate_input_path(clip.asset_id):
|
||||
raise ValueError(f"不合法的输入路径: {clip.asset_id}")
|
||||
|
||||
# 生成默认输出路径并校验
|
||||
if output_path is None:
|
||||
output_path = self._generate_output_path()
|
||||
|
||||
# P0: 校验输出路径
|
||||
validated_output = self._validate_output_path(output_path)
|
||||
|
||||
logger.info(f"合成视频,片段数: {len(clips)}, 输出: {validated_output}")
|
||||
|
||||
# 获取输入路径列表
|
||||
input_paths = [clip.asset_id for clip in clips]
|
||||
|
||||
try:
|
||||
if self.config.mode == EditingMode.ONE_TAKE:
|
||||
return self._one_take(input_paths, validated_output, clips)
|
||||
elif self.config.mode == EditingMode.PIP:
|
||||
return self._pip(input_paths, validated_output)
|
||||
elif self.config.mode == EditingMode.VOICE_OVER:
|
||||
return self._voice_over(input_paths, validated_output)
|
||||
elif self.config.mode == EditingMode.VOICE_PIP:
|
||||
return self._voice_pip(input_paths, validated_output)
|
||||
else:
|
||||
raise ValueError(f"不支持的剪辑模式: {self.config.mode}")
|
||||
except Exception as e:
|
||||
logger.error(f"视频合成失败: {e}")
|
||||
raise VideoComposeError(f"视频合成失败: {e}") from e
|
||||
|
||||
def _generate_output_path(self) -> str:
|
||||
"""生成输出文件路径"""
|
||||
os.makedirs(self.work_dir, exist_ok=True)
|
||||
return os.path.join(self.work_dir, f"output_{self.config.mode}_{os.getpid()}.mp4")
|
||||
|
||||
def _validate_inputs(self, video_paths: list[str], audio_path: Optional[str] = None) -> None:
|
||||
"""验证输入文件存在"""
|
||||
for path in video_paths:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"视频文件不存在: {path}")
|
||||
if not os.path.getsize(path) > 0:
|
||||
raise ValueError(f"视频文件为空: {path}")
|
||||
|
||||
if audio_path and not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
|
||||
|
||||
def _run_ffmpeg(self, command: list[str], capture_output: bool = True) -> tuple:
|
||||
"""执行 FFmpeg 命令"""
|
||||
logger.debug(f"Running FFmpeg: {' '.join(command)}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
text=capture_output,
|
||||
)
|
||||
return result.stdout or "", result.stderr or ""
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr.decode() if e.stderr else str(e)
|
||||
logger.error(f"FFmpeg error: {stderr}")
|
||||
raise RuntimeError(f"FFmpeg 执行失败: {stderr}") from e
|
||||
|
||||
def _get_video_info(self, video_path: str) -> dict:
|
||||
"""获取视频信息"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
self._ffprobe_bin,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration,codec_name",
|
||||
"-show_entries",
|
||||
"format=duration,size",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get("streams", [{}])
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
|
||||
fmt = data.get("format", {})
|
||||
|
||||
fps_str = video_stream.get("r_frame_rate", "25/1")
|
||||
fps_parts = fps_str.split("/")
|
||||
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0])
|
||||
|
||||
return {
|
||||
"width": int(video_stream.get("width", 0)),
|
||||
"height": int(video_stream.get("height", 0)),
|
||||
"fps": fps,
|
||||
"duration": float(fmt.get("duration", 0)),
|
||||
"codec": video_stream.get("codec_name", "unknown"),
|
||||
"size": int(fmt.get("size", 0)),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"获取视频信息失败 {video_path}: {e}")
|
||||
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
|
||||
|
||||
def _get_pip_position_offset(
|
||||
self, main_width: int, main_height: int, pip_width: int, pip_height: int
|
||||
) -> tuple[int, int]:
|
||||
"""获取画中画位置偏移量"""
|
||||
margin = 10
|
||||
position_offsets = {
|
||||
PIPPosition.TOP_LEFT: (margin, margin),
|
||||
PIPPosition.TOP_RIGHT: (main_width - pip_width - margin, margin),
|
||||
PIPPosition.BOTTOM_LEFT: (margin, main_height - pip_height - margin),
|
||||
PIPPosition.BOTTOM_RIGHT: (main_width - pip_width - margin, main_height - pip_height - margin),
|
||||
}
|
||||
return position_offsets.get(self.config.pip_position, position_offsets[PIPPosition.TOP_RIGHT])
|
||||
|
||||
def _normalize_video(self, input_path: str, output_path: str) -> dict:
|
||||
"""标准化视频格式"""
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-r",
|
||||
str(self.config.output_fps),
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r",
|
||||
str(self.config.output_fps),
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-an",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return self._get_video_info(output_path)
|
||||
|
||||
def _one_take(self, video_paths: list[str], output_path: str, clips: list[Clip]) -> str:
|
||||
"""一镜到底模式"""
|
||||
if len(video_paths) == 1:
|
||||
return self._normalize_video(video_paths[0], output_path)
|
||||
|
||||
normalized_paths = []
|
||||
for i, path in enumerate(video_paths):
|
||||
normalized = os.path.join(self.work_dir, f"normalized_{i}_{os.getpid()}.mp4")
|
||||
self._normalize_video(path, normalized)
|
||||
normalized_paths.append(normalized)
|
||||
|
||||
durations = [self._get_video_info(p)["duration"] for p in normalized_paths]
|
||||
|
||||
if len(normalized_paths) <= 5:
|
||||
output_path = self._one_take_with_xfade(normalized_paths, durations, output_path, clips)
|
||||
else:
|
||||
output_path = self._one_take_simple_concat(normalized_paths, output_path)
|
||||
|
||||
for p in normalized_paths:
|
||||
try:
|
||||
if p != output_path:
|
||||
os.remove(p)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def _one_take_with_xfade(
|
||||
self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip]
|
||||
) -> str:
|
||||
"""使用 xfade 滤镜实现转场 (P1-2: 转场参数白名单校验)"""
|
||||
if len(normalized_paths) == 2:
|
||||
# 获取当前片段的转场效果并校验白名单
|
||||
transition = "fade"
|
||||
if len(clips) > 1:
|
||||
transition = self._get_validated_transition(clips[1].transition)
|
||||
|
||||
trans_duration = self.config.transition_duration
|
||||
offset1 = durations[0] - trans_duration / 2
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
normalized_paths[0],
|
||||
"-i",
|
||||
normalized_paths[1],
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return output_path
|
||||
else:
|
||||
return self._one_take_simple_concat(normalized_paths, output_path)
|
||||
|
||||
def _one_take_simple_concat(self, normalized_paths: list[str], output_path: str) -> str:
|
||||
"""使用 concat demuxer 简单拼接"""
|
||||
concat_file = os.path.join(self.work_dir, f"concat_list_{os.getpid()}.txt")
|
||||
with open(concat_file, "w") as f:
|
||||
for path in normalized_paths:
|
||||
f.write(f"file '{os.path.abspath(path)}'\n")
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
try:
|
||||
os.remove(concat_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def _pip(self, video_paths: list[str], output_path: str) -> str:
|
||||
"""画中画模式"""
|
||||
if not video_paths:
|
||||
raise ValueError("No video paths provided")
|
||||
|
||||
main_video = video_paths[0]
|
||||
main_normalized = os.path.join(self.work_dir, f"main_{os.getpid()}.mp4")
|
||||
main_info = self._normalize_video(main_video, main_normalized)
|
||||
|
||||
if len(video_paths) == 1:
|
||||
os.rename(main_normalized, output_path)
|
||||
return output_path
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4")
|
||||
pip_info = self._get_video_info(video_paths[1])
|
||||
|
||||
if pip_info["duration"] > main_info["duration"]:
|
||||
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
temp_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = temp_pip
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
pip_normalized,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = pip_normalized
|
||||
|
||||
if main_info["duration"] > pip_info["duration"]:
|
||||
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = looped_pip
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
main_normalized,
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
for temp_file in [main_normalized, pip_normalized]:
|
||||
if temp_file and temp_file != output_path:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def _voice_over(self, video_paths: list[str], audio_path: str, output_path: str) -> str:
|
||||
"""口播模式"""
|
||||
if not audio_path:
|
||||
raise ValueError("audio_path is required for VOICE_OVER mode")
|
||||
|
||||
if not video_paths:
|
||||
raise ValueError("No background video provided")
|
||||
|
||||
audio_info = self._get_video_info(audio_path)
|
||||
audio_duration = audio_info["duration"]
|
||||
|
||||
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
|
||||
bg_info = self._normalize_video(video_paths[0], bg_normalized)
|
||||
|
||||
if bg_info["duration"] < audio_duration:
|
||||
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = looped_bg
|
||||
elif bg_info["duration"] > audio_duration:
|
||||
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
temp_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = temp_bg
|
||||
|
||||
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-vf",
|
||||
f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
blurred_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
blurred_bg,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
for temp_file in [bg_normalized, blurred_bg]:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def _voice_pip(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str:
|
||||
"""口播+画中画模式"""
|
||||
if not video_paths:
|
||||
raise ValueError("No video paths provided")
|
||||
|
||||
if len(video_paths) == 1:
|
||||
return self._normalize_video(video_paths[0], output_path)
|
||||
|
||||
voice_video = video_paths[0]
|
||||
bg_video = video_paths[1] if len(video_paths) > 1 else video_paths[0]
|
||||
|
||||
voice_normalized = os.path.join(self.work_dir, f"voice_{os.getpid()}.mp4")
|
||||
voice_info = self._normalize_video(voice_video, voice_normalized)
|
||||
|
||||
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
|
||||
bg_info = self._normalize_video(bg_video, bg_normalized)
|
||||
|
||||
final_duration = min(voice_info["duration"], bg_info["duration"])
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
voice_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
voice_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
bg_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
if audio_path:
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"2:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
for temp_file in [voice_normalized, voice_adjusted, bg_normalized, bg_adjusted]:
|
||||
try:
|
||||
if temp_file != output_path:
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def create_compose_service(mode: str, work_dir: Optional[str] = None, **kwargs) -> VideoComposeService:
|
||||
"""便捷工厂函数:创建视频合成服务"""
|
||||
try:
|
||||
editing_mode = EditingMode(mode)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效的剪辑模式: {mode}. 有效模式: {[m.value for m in EditingMode]}")
|
||||
|
||||
config = EditingModeConfig(
|
||||
mode=editing_mode,
|
||||
output_width=kwargs.get("output_width", 1280),
|
||||
output_height=kwargs.get("output_height", 720),
|
||||
output_fps=kwargs.get("output_fps", 25),
|
||||
pip_position=PIPPosition(kwargs.get("pip_position", "top_right")),
|
||||
pip_scale=kwargs.get("pip_scale", 0.25),
|
||||
transition_duration=kwargs.get("transition_duration", 0.5),
|
||||
)
|
||||
|
||||
return VideoComposeService(config=config, work_dir=work_dir)
|
||||
Regular → Executable
+3
@@ -18,6 +18,9 @@ class WorkerSettings(BaseSettings):
|
||||
environment: str = "development"
|
||||
auto_create_schema: bool = False
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
render_engine: str = "legacy"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -39,6 +39,10 @@ def _get_job_service():
|
||||
def compose_video(self, job_id: str, **kwargs):
|
||||
"""视频合成任务。
|
||||
|
||||
根据 RENDER_ENGINE 配置选择渲染引擎:
|
||||
- legacy: 旧 VideoComposeService(filter_complex 模式)
|
||||
- unified: 新 UnifiedRenderService(图层架构)
|
||||
|
||||
Args:
|
||||
job_id: JobService 中的任务 ID
|
||||
**kwargs: 来自 Job.payload 的额外参数(plan_id, output_path 等)
|
||||
@@ -56,66 +60,18 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
job_service.fail_job(job_id, "Missing plan_id in job payload")
|
||||
return {"status": "error", "message": "Missing plan_id"}
|
||||
|
||||
# 标记为 running
|
||||
job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境")
|
||||
# 判断使用哪个渲染引擎
|
||||
# 优先级:Redis Feature Flag(白名单 > 百分比) > 环境变量默认
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
# 延迟导入 VideoComposeService
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
resolver = get_render_engine_resolver()
|
||||
user_id = job.created_by_user_id or None
|
||||
engine = resolver.get_engine(user_id=user_id)
|
||||
|
||||
compose_svc = VideoComposeService(db)
|
||||
|
||||
# 校验合成条件
|
||||
job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件")
|
||||
validation = compose_svc.validate_compose(plan_id)
|
||||
if not validation.valid:
|
||||
error_msg = "; ".join(validation.errors)
|
||||
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 构建合成命令
|
||||
job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令")
|
||||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||||
compose_cmd = compose_svc.build_compose_command(plan_id, output_path)
|
||||
|
||||
# 执行 FFmpeg
|
||||
job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成")
|
||||
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
|
||||
raise
|
||||
|
||||
# 上传结果
|
||||
job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果")
|
||||
storage_key = f"rendered/{plan_id}/{job_id}.mp4"
|
||||
|
||||
from worker_app.tasks.edit_plan_generation import _upload_to_oss
|
||||
|
||||
output_url = _upload_to_oss(Path(output_path), storage_key)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
"output_path": output_path,
|
||||
"storage_key": storage_key,
|
||||
"output_url": output_url or "",
|
||||
"estimated_duration": compose_cmd.estimated_duration,
|
||||
"clip_count": len(compose_cmd.clip_chains),
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
logger.info("视频合成完成: job_id=%s, plan_id=%s", job_id, plan_id)
|
||||
return {"status": "completed", "job_id": job_id, "result": result_data}
|
||||
if engine == "unified":
|
||||
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
|
||||
else:
|
||||
return _compose_with_legacy_engine(self, job_service, job, plan_id, db)
|
||||
|
||||
except self.retry_exc as exc:
|
||||
logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc)
|
||||
@@ -129,11 +85,145 @@ def compose_video(self, job_id: str, **kwargs):
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
finally:
|
||||
db.close()
|
||||
# 清理临时文件
|
||||
|
||||
|
||||
def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dict:
|
||||
"""旧引擎渲染路径(VideoComposeService)。"""
|
||||
job_id = job.id
|
||||
|
||||
# 标记为 running
|
||||
job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境")
|
||||
|
||||
# 延迟导入 VideoComposeService
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
|
||||
compose_svc = VideoComposeService(db)
|
||||
|
||||
# 校验合成条件
|
||||
job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件")
|
||||
validation = compose_svc.validate_compose(plan_id)
|
||||
if not validation.valid:
|
||||
error_msg = "; ".join(validation.errors)
|
||||
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 构建合成命令
|
||||
job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令")
|
||||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||||
compose_cmd = compose_svc.build_compose_command(plan_id, output_path)
|
||||
|
||||
# 执行 FFmpeg
|
||||
job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成")
|
||||
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
|
||||
raise
|
||||
|
||||
# 上传结果
|
||||
job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果")
|
||||
storage_key = f"rendered/{plan_id}/{job_id}.mp4"
|
||||
|
||||
from worker_app.tasks.edit_plan_generation import _upload_to_oss
|
||||
|
||||
output_url = _upload_to_oss(Path(output_path), storage_key)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
"output_path": output_path,
|
||||
"storage_key": storage_key,
|
||||
"output_url": output_url or "",
|
||||
"estimated_duration": compose_cmd.estimated_duration,
|
||||
"clip_count": len(compose_cmd.clip_chains),
|
||||
"engine": "legacy",
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
logger.info("视频合成完成(legacy): job_id=%s, plan_id=%s", job_id, plan_id)
|
||||
return {"status": "completed", "job_id": job_id, "result": result_data}
|
||||
|
||||
|
||||
def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict:
|
||||
"""新引擎渲染路径(UnifiedRenderService + RenderAdapter)。"""
|
||||
job_id = job.id
|
||||
|
||||
# 标记为 running
|
||||
job_service.update_progress(job_id, progress=10.0, current_stage="初始化统一渲染引擎")
|
||||
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
adapter = RenderAdapter(db)
|
||||
|
||||
# 校验合成条件
|
||||
job_service.update_progress(job_id, progress=15.0, current_stage="校验合成条件")
|
||||
valid, errors, warnings, ready_count, total_count = adapter.validate_plan(plan_id)
|
||||
if not valid:
|
||||
error_msg = "; ".join(errors)
|
||||
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 进度回调
|
||||
def progress_cb(progress: float, stage: str) -> None:
|
||||
try:
|
||||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||||
if Path(output_path).exists():
|
||||
Path(output_path).unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/worker/worker_app/tasks/compose_video.py: {e}", exc_info=True)
|
||||
job_service.update_progress(job_id, progress=progress, current_stage=stage)
|
||||
except Exception:
|
||||
logger.exception("更新进度失败")
|
||||
|
||||
# 执行渲染
|
||||
job_service.update_progress(job_id, progress=20.0, current_stage="开始渲染")
|
||||
logger.info("统一渲染引擎开始: job_id=%s plan_id=%s", job_id, plan_id)
|
||||
|
||||
result = adapter.render_plan(
|
||||
plan_id=plan_id,
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
job_service.fail_job(job_id, f"渲染失败: {result.error_message}")
|
||||
raise RuntimeError(result.error_message)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
"output_path": str(result.output_path) if result.output_path else "",
|
||||
"storage_key": f"rendered/{plan_id}/{job_id}.mp4",
|
||||
"output_url": result.output_url,
|
||||
"estimated_duration": result.duration,
|
||||
"clip_count": result.clip_count,
|
||||
"engine": "unified",
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"file_size": result.file_size,
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
logger.info(
|
||||
"视频合成完成(unified): job_id=%s plan_id=%s duration=%.2fs",
|
||||
job_id,
|
||||
plan_id,
|
||||
result.duration,
|
||||
)
|
||||
return {"status": "completed", "job_id": job_id, "result": result_data}
|
||||
|
||||
|
||||
def _cleanup_output(job_id: str) -> None:
|
||||
"""清理临时输出文件。"""
|
||||
try:
|
||||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||||
if Path(output_path).exists():
|
||||
Path(output_path).unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"清理输出文件失败: {e}", exc_info=True)
|
||||
|
||||
Regular → Executable
+347
-220
@@ -1,179 +1,46 @@
|
||||
"""剪辑计划渲染任务 — Phase 8 任务 2.05.
|
||||
"""剪辑计划渲染任务 — 支持 Feature Flag 灰度.
|
||||
|
||||
Celery 任务 worker.render_edit_plan:
|
||||
1. 加载 EditPlan + EditPlanClips
|
||||
2. 下载各片段素材
|
||||
3. 按 order 顺序拼接片段
|
||||
2. 根据 Feature Flag 选择渲染引擎(legacy / unified)
|
||||
3. 下载各片段素材 + 渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 更新 EditPlan / EditPlanClip 状态
|
||||
6. 更新 GenerationTask 进度
|
||||
5. 创建 GeneratedVideo 记录 + 查重
|
||||
6. 更新 EditPlan / EditPlanClip 状态
|
||||
7. 更新 GenerationTask 进度
|
||||
|
||||
渲染引擎灰度:
|
||||
- 走 Feature Flag (render_engine) 控制
|
||||
- legacy: VideoComposeService + FFmpeg filter_complex
|
||||
- unified: UnifiedRenderService 图层架构
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import oss2
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN = shutil.which("ffprobe") or "ffprobe"
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
|
||||
|
||||
# ── OSS helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _oss_settings() -> tuple[str, str, str, str] | None:
|
||||
"""获取 OSS 配置"""
|
||||
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
|
||||
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
|
||||
endpoint = os.getenv("OSS_ENDPOINT")
|
||||
bucket_name = os.getenv("OSS_BUCKET_NAME")
|
||||
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
|
||||
return None
|
||||
return access_key_id, access_key_secret, endpoint, bucket_name
|
||||
|
||||
|
||||
def _oss_bucket() -> oss2.Bucket | None:
|
||||
"""获取 OSS Bucket"""
|
||||
settings = _oss_settings()
|
||||
if settings is None:
|
||||
return None
|
||||
access_key_id, access_key_secret, endpoint, bucket_name = settings
|
||||
return oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
|
||||
|
||||
|
||||
def _normalize_storage_key(storage_key_or_url: str) -> str:
|
||||
"""标准化存储键"""
|
||||
if storage_key_or_url.startswith(("http://", "https://")):
|
||||
return urlparse(storage_key_or_url).path.lstrip("/")
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
|
||||
def _download_asset(asset_storage_key: str, local_path: Path) -> bool:
|
||||
"""下载素材文件到本地"""
|
||||
bucket = _oss_bucket()
|
||||
if bucket is None:
|
||||
return False
|
||||
try:
|
||||
bucket.get_object_to_file(_normalize_storage_key(asset_storage_key), str(local_path))
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("下载素材失败: %s", asset_storage_key)
|
||||
return False
|
||||
|
||||
|
||||
def _upload_to_oss(local_path: Path, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL"""
|
||||
bucket = _oss_bucket()
|
||||
if bucket is None:
|
||||
return None
|
||||
try:
|
||||
bucket.put_object_from_file(storage_key, str(local_path))
|
||||
settings = _oss_settings()
|
||||
if settings:
|
||||
_, _, endpoint, bucket_name = settings
|
||||
return f"https://{bucket_name}.{endpoint.replace('https://', '').replace('http://', '')}/{storage_key}"
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("上传 OSS 失败: %s", storage_key)
|
||||
return None
|
||||
|
||||
|
||||
# ── FFmpeg helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_ffmpeg(command: list[str]) -> None:
|
||||
"""执行 FFmpeg 命令"""
|
||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603
|
||||
|
||||
|
||||
def _probe_duration(local_path: Path) -> float:
|
||||
"""获取视频/音频时长"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _concatenate_clips(
|
||||
clip_paths: list[Path],
|
||||
output_path: Path,
|
||||
transition_effects: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""将多个片段拼接为最终视频
|
||||
|
||||
使用 FFmpeg concat demuxer 实现。
|
||||
"""
|
||||
if not clip_paths:
|
||||
return False
|
||||
|
||||
if len(clip_paths) == 1:
|
||||
# 单片段直接复制
|
||||
try:
|
||||
shutil.copy2(str(clip_paths[0]), str(output_path))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 多片段:使用 concat demuxer
|
||||
concat_file = output_path.parent / "concat_list.txt"
|
||||
try:
|
||||
with open(concat_file, "w") as f:
|
||||
for p in clip_paths:
|
||||
f.write(f"file '{p}'\n")
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_file),
|
||||
"-c",
|
||||
"copy",
|
||||
str(output_path),
|
||||
]
|
||||
_run_ffmpeg(command)
|
||||
return output_path.exists() and output_path.stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("拼接片段失败")
|
||||
return False
|
||||
finally:
|
||||
if concat_file.exists():
|
||||
concat_file.unlink()
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
upload_to_oss,
|
||||
)
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
# ── Repository imports (延迟导入避免循环依赖) ─────────────────────────────────
|
||||
|
||||
@@ -201,21 +68,277 @@ def _get_repos():
|
||||
# ── Celery Task ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_render_engine(user_id: str) -> str:
|
||||
"""根据 Feature Flag 决定使用哪个渲染引擎。
|
||||
|
||||
Returns:
|
||||
"legacy" 或 "unified"
|
||||
"""
|
||||
try:
|
||||
from video_processing.render_engine_resolver import get_render_engine_resolver
|
||||
|
||||
resolver = get_render_engine_resolver()
|
||||
return resolver.get_engine(user_id=user_id)
|
||||
except Exception as exc:
|
||||
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
|
||||
return "legacy"
|
||||
|
||||
|
||||
def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg: str):
|
||||
"""统一的计划失败标记工具。"""
|
||||
plan = plan_repo.get(plan_id)
|
||||
if plan and plan.status.value == "rendering":
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.status.value != "failed":
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = error_msg
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
|
||||
def _finalize_render_success(
|
||||
plan,
|
||||
plan_repo,
|
||||
clip_repo,
|
||||
gen_task_repo,
|
||||
db,
|
||||
plan_id: str,
|
||||
output_url: str,
|
||||
storage_key: str,
|
||||
duration: float,
|
||||
file_size: int,
|
||||
width: int,
|
||||
height: int,
|
||||
rendered_clip_ids: list[str],
|
||||
failed_clip_ids: list[str],
|
||||
generation_task_id: str,
|
||||
output_path: Path,
|
||||
engine: str,
|
||||
) -> dict:
|
||||
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
project_id = plan.project_id or ""
|
||||
batch_id = plan.config.get("batch_id", "")
|
||||
mode = plan.config.get("mode", "edit_plan")
|
||||
if generation_task_id and project_id:
|
||||
try:
|
||||
create_video_record_and_dedup(
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
file_url=output_url or "",
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=str(output_path),
|
||||
mode=mode,
|
||||
session=db,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=OUTPUT_FPS,
|
||||
)
|
||||
except Exception as dedup_err:
|
||||
logger.warning("查重失败(不影响渲染结果): %s", dedup_err)
|
||||
|
||||
# 更新片段状态为 rendered
|
||||
for clip_id in rendered_clip_ids:
|
||||
clip = clip_repo.get(clip_id)
|
||||
if clip and clip.status.value == "ready":
|
||||
clip.mark_rendered()
|
||||
clip_repo.update(clip)
|
||||
|
||||
# 更新 EditPlan 状态为 completed
|
||||
plan.config["rendered_url"] = output_url or ""
|
||||
plan.config["rendered_storage_key"] = storage_key
|
||||
plan.mark_completed()
|
||||
plan_repo.update(plan)
|
||||
|
||||
# 更新 GenerationTask 状态为 completed
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.status = "completed"
|
||||
gen_task.progress = 100.0
|
||||
gen_task.result_count = len(rendered_clip_ids)
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
logger.info(
|
||||
"剪辑计划渲染完成: plan_id=%s engine=%s rendered=%d failed=%d duration=%.1fs",
|
||||
plan_id,
|
||||
engine,
|
||||
len(rendered_clip_ids),
|
||||
len(failed_clip_ids),
|
||||
duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"plan_id": plan_id,
|
||||
"rendered_count": len(rendered_clip_ids),
|
||||
"failed_count": len(failed_clip_ids),
|
||||
"output_url": output_url,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
|
||||
def _render_with_unified(
|
||||
plan,
|
||||
clips,
|
||||
asset_path_map: dict[str, Path],
|
||||
tmpdir_path: Path,
|
||||
rendered_clip_ids: list[str],
|
||||
plan_id: str,
|
||||
generation_task_id: str,
|
||||
plan_repo,
|
||||
clip_repo,
|
||||
gen_task_repo,
|
||||
db,
|
||||
) -> dict:
|
||||
"""统一渲染引擎路径(UnifiedRenderService 图层架构)。"""
|
||||
render_service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=tmpdir_path,
|
||||
output_width=OUTPUT_WIDTH,
|
||||
output_height=OUTPUT_HEIGHT,
|
||||
output_fps=int(OUTPUT_FPS),
|
||||
)
|
||||
|
||||
try:
|
||||
render_result = render_service.render()
|
||||
except Exception as render_err:
|
||||
logger.error("渲染失败(unified): %s — %s", plan_id, render_err)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"渲染失败: {render_err}")
|
||||
return {"status": "error", "message": f"渲染失败: {render_err}"}
|
||||
|
||||
output_path = render_result.output_path
|
||||
|
||||
# 上传到 OSS
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
output_url = upload_to_oss(output_path, storage_key)
|
||||
|
||||
failed_clip_ids: list[str] = []
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
plan_id=plan_id,
|
||||
output_url=output_url or "",
|
||||
storage_key=storage_key,
|
||||
duration=render_result.duration,
|
||||
file_size=render_result.file_size,
|
||||
width=render_result.width,
|
||||
height=render_result.height,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
generation_task_id=generation_task_id,
|
||||
output_path=output_path,
|
||||
engine="unified",
|
||||
)
|
||||
|
||||
|
||||
def _render_with_legacy(
|
||||
plan,
|
||||
clips,
|
||||
rendered_clip_ids: list[str],
|
||||
failed_clip_ids: list[str],
|
||||
tmpdir_path: Path,
|
||||
plan_id: str,
|
||||
generation_task_id: str,
|
||||
plan_repo,
|
||||
clip_repo,
|
||||
gen_task_repo,
|
||||
db,
|
||||
) -> dict:
|
||||
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
|
||||
compose_svc = VideoComposeService(db)
|
||||
|
||||
# 校验合成条件
|
||||
validation = compose_svc.validate_compose(plan_id)
|
||||
if not validation.valid:
|
||||
error_msg = "; ".join(validation.errors)
|
||||
logger.error("合成校验失败(legacy): %s — %s", plan_id, error_msg)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 构建 FFmpeg 命令
|
||||
output_dir = os.environ.get("VIDEO_OUTPUT_DIR", str(tmpdir_path))
|
||||
output_path = Path(output_dir) / f"{plan_id}.mp4"
|
||||
compose_cmd = compose_svc.build_compose_command(plan_id, str(output_path))
|
||||
|
||||
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
|
||||
try:
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"FFmpeg 执行失败: {e.stderr[:500]}"
|
||||
logger.error("FFmpeg 执行失败(legacy): %s — %s", plan_id, error_msg)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 获取文件大小
|
||||
file_size = output_path.stat().st_size if output_path.exists() else 0
|
||||
duration = compose_cmd.estimated_duration or 0.0
|
||||
|
||||
# 上传到 OSS
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
output_url = upload_to_oss(output_path, storage_key)
|
||||
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
plan_id=plan_id,
|
||||
output_url=output_url or "",
|
||||
storage_key=storage_key,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
width=OUTPUT_WIDTH,
|
||||
height=OUTPUT_HEIGHT,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
generation_task_id=generation_task_id,
|
||||
output_path=output_path,
|
||||
engine="legacy",
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.render_edit_plan", bind=True, max_retries=2)
|
||||
def render_edit_plan(self, plan_id: str) -> dict:
|
||||
"""渲染剪辑计划
|
||||
|
||||
流程:
|
||||
1. 加载 EditPlan + EditPlanClips
|
||||
2. 下载各片段素材到临时目录
|
||||
3. 按 order 顺序拼接片段
|
||||
2. 根据 Feature Flag 选择渲染引擎(legacy / unified)
|
||||
3. 下载素材 + 渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 更新 EditPlan → completed, EditPlanClips → rendered
|
||||
6. 更新 GenerationTask 进度
|
||||
5. 创建 GeneratedVideo 记录 + 查重
|
||||
6. 更新 EditPlan → completed, EditPlanClips → rendered
|
||||
7. 更新 GenerationTask 进度
|
||||
"""
|
||||
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
|
||||
|
||||
generation_task_id = ""
|
||||
engine = "legacy"
|
||||
|
||||
for repos in _get_repos():
|
||||
plan_repo, clip_repo, gen_task_repo, db = repos
|
||||
@@ -230,7 +353,12 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
# 获取 generation_task_id(提前读取,确保 except 块可用)
|
||||
generation_task_id = plan.config.get("generation_task_id", "")
|
||||
|
||||
# 2. 加载片段列表(按 order 排序)
|
||||
# 2. 选择渲染引擎(Feature Flag 灰度控制)
|
||||
user_id = plan.created_by_user_id or ""
|
||||
engine = _resolve_render_engine(user_id)
|
||||
logger.info("剪辑计划渲染引擎: plan_id=%s engine=%s user_id=%s", plan_id, engine, user_id)
|
||||
|
||||
# 3. 加载片段列表(按 order 排序)
|
||||
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
if not clips:
|
||||
logger.warning("剪辑计划没有片段: %s", plan_id)
|
||||
@@ -246,13 +374,22 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task.started_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
# 3. 下载素材并拼接
|
||||
# 3. 下载素材并构建 asset_path_map
|
||||
with tempfile.TemporaryDirectory(prefix="edit_plan_") as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
clip_paths: list[Path] = []
|
||||
asset_path_map: dict[str, Path] = {}
|
||||
rendered_clip_ids: list[str] = []
|
||||
failed_clip_ids: list[str] = []
|
||||
|
||||
# 预先批量查询所有素材的 storage_key(file_url)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
asset_storage_map: dict[str, str] = {}
|
||||
if clip_asset_ids:
|
||||
assets = db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
|
||||
asset_storage_map = {a.id: a.file_url for a in assets if a.file_url}
|
||||
|
||||
for clip in clips:
|
||||
if not clip.asset_id:
|
||||
# 没有素材的片段跳过,标记为失败
|
||||
@@ -261,18 +398,35 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
failed_clip_ids.append(clip.id)
|
||||
continue
|
||||
|
||||
if clip.asset_id in asset_path_map:
|
||||
# 同一素材已下载(多个 clip 共享同一素材)
|
||||
rendered_clip_ids.append(clip.id)
|
||||
continue
|
||||
|
||||
storage_key = asset_storage_map.get(clip.asset_id)
|
||||
if not storage_key:
|
||||
logger.warning(
|
||||
"片段素材无 storage_key,跳过: clip_id=%s asset_id=%s",
|
||||
clip.id,
|
||||
clip.asset_id,
|
||||
)
|
||||
clip.mark_failed()
|
||||
clip_repo.update(clip)
|
||||
failed_clip_ids.append(clip.id)
|
||||
continue
|
||||
|
||||
# 下载素材
|
||||
ext = Path(clip.asset_id).suffix or ".mp4"
|
||||
ext = Path(storage_key).suffix or ".mp4"
|
||||
local_path = tmpdir_path / f"clip_{clip.order:04d}{ext}"
|
||||
if _download_asset(clip.asset_id, local_path):
|
||||
clip_paths.append(local_path)
|
||||
if download_asset(storage_key, local_path):
|
||||
asset_path_map[clip.asset_id] = local_path
|
||||
rendered_clip_ids.append(clip.id)
|
||||
else:
|
||||
clip.mark_failed()
|
||||
clip_repo.update(clip)
|
||||
failed_clip_ids.append(clip.id)
|
||||
|
||||
if not clip_paths:
|
||||
if not asset_path_map:
|
||||
logger.error("所有片段素材下载失败: %s", plan_id)
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
@@ -285,65 +439,38 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task_repo.update(gen_task)
|
||||
return {"status": "error", "message": "所有片段素材下载失败"}
|
||||
|
||||
# 4. 拼接片段
|
||||
output_path = tmpdir_path / f"rendered_{plan_id}.mp4"
|
||||
transition_effects = [c.transition_effect for c in clips if c.asset_id]
|
||||
success = _concatenate_clips(clip_paths, output_path, transition_effects)
|
||||
# 4. 根据引擎选择渲染方式
|
||||
if engine == "unified":
|
||||
result = _render_with_unified(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
tmpdir_path=tmpdir_path,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
plan_id=plan_id,
|
||||
generation_task_id=generation_task_id,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
result = _render_with_legacy(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
tmpdir_path=tmpdir_path,
|
||||
plan_id=plan_id,
|
||||
generation_task_id=generation_task_id,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error("片段拼接失败: %s", plan_id)
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = "片段拼接失败"
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
return {"status": "error", "message": "片段拼接失败"}
|
||||
|
||||
# 5. 上传到 OSS
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
output_url = _upload_to_oss(output_path, storage_key)
|
||||
|
||||
# 6. 更新片段状态为 rendered
|
||||
for clip_id in rendered_clip_ids:
|
||||
clip = clip_repo.get(clip_id)
|
||||
if clip and clip.status.value == "ready":
|
||||
clip.mark_rendered()
|
||||
clip_repo.update(clip)
|
||||
|
||||
# 7. 更新 EditPlan 状态为 completed
|
||||
plan.config["rendered_url"] = output_url or ""
|
||||
plan.config["rendered_storage_key"] = storage_key
|
||||
plan.mark_completed()
|
||||
plan_repo.update(plan)
|
||||
|
||||
# 8. 更新 GenerationTask 状态为 completed
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.status = "completed"
|
||||
gen_task.progress = 100.0
|
||||
gen_task.result_count = len(rendered_clip_ids)
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
logger.info(
|
||||
"剪辑计划渲染完成: plan_id=%s rendered=%d failed=%d",
|
||||
plan_id,
|
||||
len(rendered_clip_ids),
|
||||
len(failed_clip_ids),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"plan_id": plan_id,
|
||||
"rendered_count": len(rendered_clip_ids),
|
||||
"failed_count": len(failed_clip_ids),
|
||||
"output_url": output_url,
|
||||
}
|
||||
result["engine"] = engine
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染剪辑计划异常: %s", plan_id)
|
||||
|
||||
Executable → Regular
+845
-267
File diff suppressed because it is too large
Load Diff
Regular → Executable
+4
-1
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from celery import Task
|
||||
from celery.exceptions import Retry
|
||||
from video_processing.oss_helpers import get_signed_download_url
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
@@ -48,7 +49,9 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
|
||||
repo = SQLAlchemyVoiceCloneProfileRepository(session)
|
||||
workflow = VoiceCloneWorkflowService(
|
||||
repository=repo,
|
||||
cosyvoice_service=CosyVoiceService(),
|
||||
cosyvoice_service=CosyVoiceService(
|
||||
audio_url_signer=lambda url: get_signed_download_url(url, expires_seconds=86400) or url
|
||||
),
|
||||
)
|
||||
|
||||
updated_profile = workflow.poll_and_process_clone(profile_id, timeout=300)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# CI 必需环境变量清单
|
||||
|
||||
> 本文档整理小虾 SaaS 项目中所有从环境变量读取的配置项,明确哪些是 CI 测试必须的、哪些是可选的。
|
||||
> 最后更新:2026-07-09
|
||||
|
||||
## 目录
|
||||
|
||||
- [一、配置来源说明](#一配置来源说明)
|
||||
- [二、CI 必需环境变量(P0)](#二ci-必需环境变量p0)
|
||||
- [三、可选环境变量(有默认值)](#三可选环境变量有默认值)
|
||||
- [四、测试专用环境变量](#四测试专用环境变量)
|
||||
- [五、Worker 服务环境变量](#五worker-服务环境变量)
|
||||
- [六、当前 CI 配置对照](#六当前-ci-配置对照)
|
||||
|
||||
---
|
||||
|
||||
## 一、配置来源说明
|
||||
|
||||
项目的环境变量配置主要来自以下几处:
|
||||
|
||||
| 来源 | 文件路径 | 说明 |
|
||||
|------|---------|------|
|
||||
| API 主配置 | `apps/api/app/config.py` | pydantic `Settings` 类,API 服务核心配置 |
|
||||
| Worker 配置 | `apps/worker/worker_app/core/config.py` | pydantic `WorkerSettings` 类,Worker 服务配置 |
|
||||
| 共享配置 | `packages/shared/config.py` | pydantic `SharedSettings` 类,API + Worker 共享配置 |
|
||||
| 直接读取 | 各模块中 `os.environ` / `os.getenv` | 散落在各业务模块中的直接读取 |
|
||||
|
||||
> **注意**:pydantic-settings 配置默认 `case_sensitive=False`,即环境变量名不区分大小写,但习惯上使用大写。
|
||||
|
||||
---
|
||||
|
||||
## 二、CI 必需环境变量(P0)
|
||||
|
||||
以下变量是 CI 运行测试**必须配置**的,缺失会导致测试启动失败或核心功能异常。
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 | 影响范围 |
|
||||
|--------|---------|--------|---------|
|
||||
| `DATABASE_URL` | 数据库连接字符串 | `postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas` | 集成测试、 Alembic 迁移验证 |
|
||||
| `USE_IN_MEMORY_DB` | 是否使用内存数据库(SQLite) | `false` | 单元测试(设为 `true` 可跳过 PostgreSQL 依赖) |
|
||||
| `JWT_SECRET_KEY` | JWT 签名密钥,**无安全默认值**,必须显式设置 | `None`(启动校验失败) | 所有涉及认证的 API 测试 |
|
||||
|
||||
> **说明**:
|
||||
> - 单元测试通过 `USE_IN_MEMORY_DB=true` 使用 SQLite 内存数据库,无需 PostgreSQL
|
||||
> - 集成测试需要真实 PostgreSQL,需设置 `DATABASE_URL`
|
||||
> - `JWT_SECRET_KEY` 在测试文件中通过 `os.environ.setdefault()` 设置了测试用默认值,CI 中可不额外配置,但生产环境必须配置
|
||||
|
||||
---
|
||||
|
||||
## 三、可选环境变量(有默认值)
|
||||
|
||||
以下变量都有合理的默认值,CI 中可以不配置,使用默认值即可。
|
||||
|
||||
### 3.1 应用基础配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `APP_NAME` | 应用名称 | `xiaoxia-saas` |
|
||||
| `APP_VERSION` | 应用版本号 | `0.1.61` / `unknown` |
|
||||
| `ENVIRONMENT` | 运行环境标识 | `development` |
|
||||
| `DEBUG` | 是否开启调试模式 | `true` |
|
||||
| `APP_BASE_URL` | 应用基础 URL(用于生成邮件链接等) | `http://localhost:3000` |
|
||||
| `API_HOST` | API 服务绑定地址 | `0.0.0.0` |
|
||||
| `API_PORT` | API 服务端口 | `8000` |
|
||||
| `API_PREFIX` | API 路由前缀 | `/api/v1` |
|
||||
| `APP_ENV` | 环境标识(用于加载 .env.{env} 文件) | `development` |
|
||||
| `LOG_LEVEL` | 日志级别 | `INFO` |
|
||||
|
||||
### 3.2 数据库连接池配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `DATABASE_POOL_SIZE` | 连接池大小 | `20` |
|
||||
| `DATABASE_MAX_OVERFLOW` | 最大溢出连接数 | `10` (API) / `40` (Worker) |
|
||||
| `DATABASE_POOL_TIMEOUT` | 获取连接超时时间(秒) | `30` |
|
||||
| `DATABASE_POOL_RECYLE` / `DATABASE_POOL_RECYCLE` | 连接回收时间(秒) | `3600` |
|
||||
| `AUTO_CREATE_SCHEMA` | 是否自动创建表结构 | `false` |
|
||||
|
||||
### 3.3 Redis / Celery 配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
|
||||
| `REDIS_MAX_CONNECTION` | Redis 最大连接数 | `50` |
|
||||
| `ENABLE_REDIS_SESSIONS` | 是否启用 Redis 会话存储 | `false` |
|
||||
| `CELERY_BROKER_URL` / `BROKER_URL` | Celery Broker 地址 | `redis://localhost:6379/0` |
|
||||
| `CELERY_RESULT_BACKEND` / `RESULT_BACKEND` | Celery 结果后端 | `redis://localhost:6379/1` |
|
||||
|
||||
### 3.4 JWT 配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `JWT_ALGORITHM` | JWT 签名算法 | `HS256`(隐式默认) |
|
||||
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | Access Token 过期时间(分钟) | `30`(隐式默认) |
|
||||
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | Refresh Token 过期时间(天) | `30`(隐式默认) |
|
||||
| `JWT_SECRET_KEY_OLD` | 旧 JWT 密钥(用于密钥轮换) | `None` |
|
||||
| `SECRET_ROTATION_DAYS` | 密钥轮换建议天数 | `90` |
|
||||
|
||||
### 3.5 邮件配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `ENABLE_EMAIL_DELIVERY` | 是否启用邮件发送 | `false` |
|
||||
| `SMTP_HOST` | SMTP 服务器地址 | `smtp.gmail.com` |
|
||||
| `SMTP_PORT` | SMTP 端口 | `587` |
|
||||
| `SMTP_USER` | SMTP 用户名 | `""`(空) |
|
||||
| `SMTP_PASSWORD` | SMTP 密码 | `""`(空) |
|
||||
| `SMTP_FROM_EMAIL` | 发件人邮箱 | `""`(空) |
|
||||
| `SMTP_FROM_NAME` | 发件人名称 | `小虾 SaaS` |
|
||||
| `SMTP_USE_TLS` | 是否使用 TLS | `true` |
|
||||
|
||||
### 3.6 OSS 阿里云存储配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliiyuncs.com` |
|
||||
| `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) |
|
||||
| `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) |
|
||||
| `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` |
|
||||
| `OSS_DIRECT_UPLOAD_MAX_MB` / `MAX_UPLOAD_SIZE_MB` | 直传最大文件大小(MB) | `2000` |
|
||||
| `OSS_DIRECT_UPLOAD_EXPIRE_SECONDS` | 直传签名过期时间(秒) | `900` |
|
||||
|
||||
### 3.7 CosyVoice 语音合成配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `COSYVOICE_API_KEY` | CosyVoice API Key | `""`(空) |
|
||||
| `COSYVOICE_BASE_URL` | CosyVoice API 地址 | `https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio` |
|
||||
| `COSYVOICE_MODEL` | CosyVoice 模型 | `cosyvoice-v1` |
|
||||
| `COSYVOICE_VOICE` | 默认音色 | `longxiaochun` |
|
||||
| `COSYVOICE_SAMPLE_RATE` | 采样率 | `22050` |
|
||||
| `COSYVOICE_FORMAT` | 输出格式 | `mp3` |
|
||||
|
||||
### 3.8 CORS 配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `CORS_ORIGINS_RAW` | CORS 允许的源(逗号分隔) | `http://localhost:3000,http://localhost:5173,http://localhost:8000` |
|
||||
|
||||
### 3.9 文件存储 / 生成文件配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `GENERATED_FILES_DIR` | 生成文件本地存储目录 | `/app/generated` |
|
||||
| `GENERATED_FILES_URL_PREFIX` | 生成文件访问 URL 前缀 | `/generated-files` |
|
||||
| `VIDEO_OUTPUT_DIR` | 视频输出目录 | `{tempdir}/video_output` |
|
||||
| `PUBLIC_API_BASE_URL` | 公开 API 基础 URL | `https://api.xiaoxiajianji.com` |
|
||||
|
||||
### 3.10 监控 / 指标配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `METRICS_AUTH_TOKEN` | Prometheus 指标接口认证 Token | `""`(空,不启用认证) |
|
||||
|
||||
### 3.11 内部 API 配置
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `INTERNAL_API_KEYS` | 内部 API 调用密钥列表(逗号分隔) | `""`(空) |
|
||||
|
||||
---
|
||||
|
||||
## 四、测试专用环境变量
|
||||
|
||||
以下变量仅在测试或冒烟测试脚本中使用。
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 | 使用位置 |
|
||||
|--------|---------|--------|---------|
|
||||
| `SMOKE_TEST_PASSWORD` | 冒烟测试用的测试账号密码 | `changeme` | `scripts/smoke_*.py` |
|
||||
| `MIGRATION_SINCE_REVISION` | 迁移安全检查的起始版本 | `None` | `scripts/check_migration_safety.py` |
|
||||
| `MIGRATION_DIFF_AGAINST` | 迁移 diff 对比的目标分支/版本 | `None` | `scripts/check_migration_safety.py` |
|
||||
|
||||
---
|
||||
|
||||
## 五、Worker 服务环境变量
|
||||
|
||||
以下变量主要用于 Worker(Celery)服务,CI 的单元/集成测试通常不涉及。
|
||||
|
||||
| 变量名 | 用途说明 | 默认值 |
|
||||
|--------|---------|--------|
|
||||
| `WORKER_NAME` | Worker 名称 | `xiaoxia-saas-worker` |
|
||||
| `WORKER_CONCURRENCY` | Worker 并发数 | `4` |
|
||||
| `WORKER_MAX_TASKS_PER_CHILD` | 每个子进程最大任务数 | `1000` |
|
||||
|
||||
---
|
||||
|
||||
## 六、当前 CI 配置对照
|
||||
|
||||
当前 `.gitea/workflows/ci-cd.yml` 中 `validate` job 配置的环境变量:
|
||||
|
||||
| 变量名 | CI 配置值 | 是否必需 | 备注 |
|
||||
|--------|----------|---------|------|
|
||||
| `DATABASE_URL` | `postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas` | ✅ 是 | Job 级别配置 |
|
||||
| `USE_IN_MEMORY_DB` | `"false"`(Job 级) / `"true"`(单元测试 step 级) | ✅ 是 | 单元测试 step 覆盖为 `true` |
|
||||
| `JWT_SECRET_KEY` | (未配置) | ⚠️ 测试内置 | 测试文件中通过 `setdefault` 设置了测试密钥 |
|
||||
|
||||
### 6.1 CI 环境变量现状评估
|
||||
|
||||
- ✅ **数据库配置完备**:DATABASE_URL + USE_IN_MEMORY_DB 已正确配置
|
||||
- ✅ **JWT 密钥**:测试代码内置默认值,CI 可正常运行
|
||||
- ⚠️ **缺少 Redis 配置**:但当前测试不依赖 Redis,使用默认值即可
|
||||
- ⚠️ **缺少邮件/OSS/语音配置**:均为可选,CI 中使用空默认值不影响核心测试
|
||||
|
||||
### 6.2 建议后续补充
|
||||
|
||||
如果未来测试覆盖到以下功能,需要在 CI 中补充对应配置:
|
||||
|
||||
1. **Redis 相关测试** → 配置 `REDIS_URL`
|
||||
2. **邮件发送测试** → 配置 `ENABLE_EMAIL_DELIVERY` 及 SMTP 相关变量
|
||||
3. **OSS 上传测试** → 配置 OSS 相关变量(或使用 mock)
|
||||
4. **语音合成测试** → 配置 CosyVoice 相关变量(或使用 mock)
|
||||
|
||||
---
|
||||
|
||||
## 附录:环境变量读取位置索引
|
||||
|
||||
### pydantic Settings 类
|
||||
- `apps/api/app/config.py` → `Settings` 类(API 主配置)
|
||||
- `apps/worker/worker_app/core/config.py` → `WorkerSettings` 类(Worker 配置)
|
||||
- `packages/shared/config.py` → `SharedSettings` 类(共享配置)
|
||||
|
||||
### 直接 os.environ / os.getenv 读取
|
||||
| 变量名 | 文件位置 |
|
||||
|--------|---------|
|
||||
| `VIDEO_OUTPUT_DIR` | `apps/worker/video_processing/video_compose_service.py`、`apps/worker/worker_app/tasks/compose_video.py` |
|
||||
| `INTERNAL_API_KEYS` | `apps/api/app/api/routes/auth.py` |
|
||||
| `APP_ENV` / `ENV` | `apps/api/app/api/routes/auth.py`、各 config.py 的 `get_settings()` |
|
||||
| `GENERATED_FILES_DIR` | `apps/worker/worker_app/tasks/generation.py`、`apps/api/main.py`、`scripts/cleanup_generated_files.py` |
|
||||
| `GENERATED_FILES_URL_PREFIX` | `apps/worker/worker_app/tasks/generation.py`、`apps/api/main.py`、`apps/api/app/core/storage.py` |
|
||||
| `PUBLIC_API_BASE_URL` | `apps/worker/worker_app/tasks/generation.py` |
|
||||
| `METRICS_AUTH_TOKEN` | `apps/api/app/middleware/prometheus_metrics.py` |
|
||||
| `APP_VERSION` | `apps/api/app/middleware/prometheus_metrics.py` |
|
||||
| `SMOKE_TEST_PASSWORD` | `scripts/smoke_*.py` |
|
||||
| `MIGRATION_SINCE_REVISION` | `scripts/check_migration_safety.py` |
|
||||
| `MIGRATION_DIFF_AGAINST` | `scripts/check_migration_safety.py` |
|
||||
| `DATABASE_URL` | `alembic/env.py` |
|
||||
@@ -473,7 +473,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -481,7 +481,7 @@
|
||||
"name": "project_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -489,7 +489,7 @@
|
||||
"name": "asset_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -783,7 +783,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -791,7 +791,7 @@
|
||||
"name": "plan_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -815,7 +815,7 @@
|
||||
"name": "template_clip_config_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -823,7 +823,7 @@
|
||||
"name": "asset_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -939,7 +939,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -947,7 +947,7 @@
|
||||
"name": "template_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -987,7 +987,7 @@
|
||||
"name": "source_edit_plan_id",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -995,7 +995,7 @@
|
||||
"name": "project_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1003,7 +1003,7 @@
|
||||
"name": "created_by_user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1071,7 +1071,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1098,6 +1098,14 @@
|
||||
"type": "VARCHAR(50)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "editing_mode",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "config",
|
||||
@@ -1181,7 +1189,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1189,7 +1197,7 @@
|
||||
"name": "project_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1197,7 +1205,7 @@
|
||||
"name": "generation_task_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1333,7 +1341,7 @@
|
||||
"name": "duplicate_of",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
@@ -1378,7 +1386,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1386,7 +1394,7 @@
|
||||
"name": "project_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1394,7 +1402,7 @@
|
||||
"name": "strategy_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1402,7 +1410,7 @@
|
||||
"name": "asset_library_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1410,7 +1418,7 @@
|
||||
"name": "voice_library_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1506,7 +1514,7 @@
|
||||
"name": "created_by_user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1514,7 +1522,7 @@
|
||||
"name": "source_edit_plan_id",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1530,7 +1538,7 @@
|
||||
"name": "batch_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1541,6 +1549,14 @@
|
||||
"type": "JSON",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "logs",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "TEXT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "created_at",
|
||||
@@ -1619,7 +1635,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1627,7 +1643,7 @@
|
||||
"name": "project_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1635,7 +1651,7 @@
|
||||
"name": "library_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1667,7 +1683,7 @@
|
||||
"name": "result_asset_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1729,7 +1745,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1737,7 +1753,7 @@
|
||||
"name": "project_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1825,7 +1841,7 @@
|
||||
"name": "source_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1833,7 +1849,7 @@
|
||||
"name": "created_by_user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1917,7 +1933,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -1925,7 +1941,7 @@
|
||||
"name": "owner_user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -2245,7 +2261,7 @@
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
@@ -2253,7 +2269,7 @@
|
||||
"name": "template_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
|
||||
+10
-13
@@ -6,14 +6,14 @@
|
||||
# 基础镜像:Python 3.12
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
@@ -21,15 +21,12 @@ WORKDIR /app
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN python -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt && rm /tmp/requirements-base.txt
|
||||
|
||||
# ---- 依赖分层:业务依赖(变化频繁)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/api/ /app/apps/api/
|
||||
@@ -40,13 +37,13 @@ COPY alembic/ /app/alembic/
|
||||
COPY scripts/ /app/scripts/
|
||||
|
||||
# 设置环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
|
||||
# API 入口点
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Worker 启动脚本 — 支持 WORKER_CONCURRENCY 环境变量
|
||||
# 未设置时默认 2(保持向后兼容)
|
||||
|
||||
set -e
|
||||
|
||||
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
|
||||
|
||||
exec celery \
|
||||
-A worker_app.celery_app \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"--concurrency=${CONCURRENCY}"
|
||||
@@ -6,6 +6,9 @@
|
||||
# 基础镜像:Python 3.12 + ffmpeg
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
@@ -48,10 +51,15 @@ COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
|
||||
# 复制 Worker 启动脚本(支持 WORKER_CONCURRENCY 环境变量)
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 设置 Python 路径
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 创建非 root 用户运行 Worker
|
||||
RUN groupadd -r celery && useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
@@ -61,4 +69,4 @@ USER celery
|
||||
|
||||
# Worker 入口点
|
||||
WORKDIR /app/apps/worker
|
||||
CMD ["celery", "-A", "worker_app.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||
CMD ["/usr/local/bin/entrypoint-worker.sh"]
|
||||
|
||||
Regular → Executable
+16
-1
@@ -1,3 +1,9 @@
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
FeatureFlagStore,
|
||||
InMemoryFeatureFlagStore,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
from packages.adapters.redis.session_store import (
|
||||
NoopSessionStore,
|
||||
RedisConfig,
|
||||
@@ -5,4 +11,13 @@ from packages.adapters.redis.session_store import (
|
||||
get_session_store,
|
||||
)
|
||||
|
||||
__all__ = ["NoopSessionStore", "RedisConfig", "SessionStore", "get_session_store"]
|
||||
__all__ = [
|
||||
"FeatureFlagConfig",
|
||||
"FeatureFlagStore",
|
||||
"InMemoryFeatureFlagStore",
|
||||
"NoopSessionStore",
|
||||
"RedisConfig",
|
||||
"RedisFeatureFlagStore",
|
||||
"SessionStore",
|
||||
"get_session_store",
|
||||
]
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
"""Feature Flag 存储实现。
|
||||
|
||||
支持两种后端:
|
||||
- RedisFeatureFlagStore:生产环境使用,支持多实例共享、热更新
|
||||
- InMemoryFeatureFlagStore:测试/开发环境使用,纯内存
|
||||
|
||||
支持的 Flag 类型:
|
||||
- 全局开关(enabled: bool)
|
||||
- 白名单(whitelist: Set[str],如 user_id 列表)
|
||||
- 百分比切流(percentage: 0-100,基于标识符哈希取模)
|
||||
|
||||
判定优先级:白名单 > 百分比 > 全局开关
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis key 前缀
|
||||
FEATURE_FLAG_REDIS_PREFIX = "feature_flag:"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureFlagConfig:
|
||||
"""单个 Feature Flag 的配置。"""
|
||||
|
||||
name: str
|
||||
enabled: bool = False
|
||||
percentage: int = 0 # 0-100
|
||||
whitelist: Set[str] = field(default_factory=set)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"enabled": self.enabled,
|
||||
"percentage": self.percentage,
|
||||
"whitelist": sorted(self.whitelist),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "FeatureFlagConfig":
|
||||
return cls(
|
||||
name=data["name"],
|
||||
enabled=bool(data.get("enabled", False)),
|
||||
percentage=int(data.get("percentage", 0)),
|
||||
whitelist=set(data.get("whitelist", [])),
|
||||
)
|
||||
|
||||
def is_active(self, identifier: Optional[str] = None) -> bool:
|
||||
"""判断当前 flag 是否激活。
|
||||
|
||||
判定优先级:
|
||||
1. 全局关闭 → False
|
||||
2. 白名单匹配 → True
|
||||
3. 百分比命中 → True
|
||||
4. 其他 → False
|
||||
|
||||
Args:
|
||||
identifier: 用于白名单匹配和百分比哈希的标识符(如 user_id)。
|
||||
传 None 时只看全局开关 + 百分比(百分比用随机值)。
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
# 白名单:精确匹配
|
||||
if identifier and identifier in self.whitelist:
|
||||
return True
|
||||
|
||||
# 百分比:0 直接 False,100 直接 True
|
||||
if self.percentage <= 0:
|
||||
# 没有白名单且百分比为0 → 未启用
|
||||
return False
|
||||
if self.percentage >= 100:
|
||||
return True
|
||||
|
||||
# 基于 identifier 做哈希取模,确保同一用户始终落在同一侧
|
||||
if identifier:
|
||||
hash_val = int(
|
||||
hashlib.md5(f"{self.name}:{identifier}".encode("utf-8")).hexdigest(), 16 # nosec B324
|
||||
) # nosec B324 - 用于哈希取模做百分比切流,非安全用途
|
||||
return (hash_val % 100) < self.percentage
|
||||
|
||||
# 无 identifier 且百分比在 0-100 之间 → 按比例随机(不保证一致性)
|
||||
import random
|
||||
|
||||
return random.randint(0, 99) < self.percentage
|
||||
|
||||
|
||||
class FeatureFlagStore(ABC):
|
||||
"""Feature Flag 存储抽象接口。"""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, name: str) -> FeatureFlagConfig:
|
||||
"""获取指定 flag 的配置,不存在则返回默认配置(关闭状态)。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def set(self, config: FeatureFlagConfig) -> None:
|
||||
"""设置 flag 配置。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, name: str) -> bool:
|
||||
"""删除 flag,返回是否成功删除。"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def list_all(self) -> dict[str, FeatureFlagConfig]:
|
||||
"""列出所有 flag。"""
|
||||
...
|
||||
|
||||
def is_active(self, name: str, identifier: Optional[str] = None) -> bool:
|
||||
"""便捷方法:判断 flag 是否激活。"""
|
||||
return self.get(name).is_active(identifier)
|
||||
|
||||
|
||||
class InMemoryFeatureFlagStore(FeatureFlagStore):
|
||||
"""内存实现,用于测试和本地开发。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._flags: dict[str, FeatureFlagConfig] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, name: str) -> FeatureFlagConfig:
|
||||
with self._lock:
|
||||
return self._flags.get(name, FeatureFlagConfig(name=name, enabled=False))
|
||||
|
||||
def set(self, config: FeatureFlagConfig) -> None:
|
||||
with self._lock:
|
||||
self._flags[config.name] = config
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
if name in self._flags:
|
||||
del self._flags[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_all(self) -> dict[str, FeatureFlagConfig]:
|
||||
with self._lock:
|
||||
return dict(self._flags)
|
||||
|
||||
|
||||
class RedisFeatureFlagStore(FeatureFlagStore):
|
||||
"""Redis 实现,支持多实例共享配置。
|
||||
|
||||
每个 flag 存在一个独立的 Redis hash key 中:
|
||||
Key: feature_flag:{name}
|
||||
Fields: enabled, percentage, whitelist(JSON array)
|
||||
"""
|
||||
|
||||
def __init__(self, redis_url: str, key_prefix: str = FEATURE_FLAG_REDIS_PREFIX) -> None:
|
||||
import redis as redis_lib
|
||||
|
||||
self._redis = redis_lib.from_url(redis_url, decode_responses=True)
|
||||
self._key_prefix = key_prefix
|
||||
# 本地缓存 + TTL,减少 Redis 调用
|
||||
self._cache: dict[str, tuple[FeatureFlagConfig, float]] = {}
|
||||
self._cache_ttl = 5.0 # 秒,默认5秒本地缓存
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _redis_key(self, name: str) -> str:
|
||||
return f"{self._key_prefix}{name}"
|
||||
|
||||
def _parse_whitelist(self, raw: Optional[str]) -> Set[str]:
|
||||
if not raw:
|
||||
return set()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return set(data) if isinstance(data, list) else set()
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return set()
|
||||
|
||||
def get(self, name: str) -> FeatureFlagConfig:
|
||||
now = time.time()
|
||||
|
||||
# 先查本地缓存
|
||||
with self._lock:
|
||||
cached = self._cache.get(name)
|
||||
if cached and now - cached[1] < self._cache_ttl:
|
||||
return cached[0]
|
||||
|
||||
# 从 Redis 读取
|
||||
try:
|
||||
key = self._redis_key(name)
|
||||
data = self._redis.hgetall(key)
|
||||
if not data:
|
||||
config = FeatureFlagConfig(name=name, enabled=False)
|
||||
else:
|
||||
config = FeatureFlagConfig(
|
||||
name=name,
|
||||
enabled=(data.get("enabled", "0") in ("1", "true", "True")),
|
||||
percentage=int(data.get("percentage", 0)),
|
||||
whitelist=self._parse_whitelist(data.get("whitelist")),
|
||||
)
|
||||
|
||||
# 写入本地缓存
|
||||
with self._lock:
|
||||
self._cache[name] = (config, now)
|
||||
|
||||
return config
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to get feature flag %s from Redis: %s", name, exc)
|
||||
# Redis 不可用时返回默认值(关闭),不影响业务
|
||||
return FeatureFlagConfig(name=name, enabled=False)
|
||||
|
||||
def set(self, config: FeatureFlagConfig) -> None:
|
||||
key = self._redis_key(config.name)
|
||||
self._redis.hset(
|
||||
key,
|
||||
mapping={
|
||||
"enabled": "1" if config.enabled else "0",
|
||||
"percentage": str(config.percentage),
|
||||
"whitelist": json.dumps(sorted(config.whitelist), ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
# 失效本地缓存
|
||||
with self._lock:
|
||||
self._cache.pop(config.name, None)
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
key = self._redis_key(name)
|
||||
result = self._redis.delete(key)
|
||||
with self._lock:
|
||||
self._cache.pop(name, None)
|
||||
return bool(result)
|
||||
|
||||
def list_all(self) -> dict[str, FeatureFlagConfig]:
|
||||
pattern = f"{self._key_prefix}*"
|
||||
result: dict[str, FeatureFlagConfig] = {}
|
||||
try:
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = self._redis.scan(cursor=cursor, match=pattern, count=100)
|
||||
for key in keys:
|
||||
name = key[len(self._key_prefix) :]
|
||||
result[name] = self.get(name)
|
||||
if cursor == 0:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to list feature flags from Redis: %s", exc)
|
||||
return result
|
||||
|
||||
def invalidate_cache(self, name: Optional[str] = None) -> None:
|
||||
"""手动失效本地缓存。"""
|
||||
with self._lock:
|
||||
if name:
|
||||
self._cache.pop(name, None)
|
||||
else:
|
||||
self._cache.clear()
|
||||
@@ -71,6 +71,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
template_type=template.template_type,
|
||||
editing_mode=template.editing_mode,
|
||||
config=template.config,
|
||||
preview_url=template.preview_url,
|
||||
sort_weight=template.sort_weight,
|
||||
@@ -89,6 +90,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
model.name = template.name
|
||||
model.description = template.description
|
||||
model.template_type = template.template_type
|
||||
model.editing_mode = template.editing_mode
|
||||
model.config = template.config
|
||||
model.preview_url = template.preview_url
|
||||
model.sort_weight = template.sort_weight
|
||||
@@ -128,6 +130,7 @@ class SQLAlchemyEditTemplateRepository:
|
||||
name=model.name,
|
||||
description=model.description or "",
|
||||
template_type=model.template_type or "default",
|
||||
editing_mode=model.editing_mode or "one_take",
|
||||
config=model.config or {},
|
||||
preview_url=model.preview_url or "",
|
||||
sort_weight=model.sort_weight or 0,
|
||||
|
||||
Regular → Executable
+20
@@ -27,6 +27,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
source_edit_plan_id=model.source_edit_plan_id or "",
|
||||
asset_select_mode=model.asset_select_mode or "",
|
||||
batch_id=model.batch_id or "",
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -56,6 +57,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
source_edit_plan_id=task.source_edit_plan_id or None,
|
||||
asset_select_mode=task.asset_select_mode or "",
|
||||
batch_id=task.batch_id or "",
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
@@ -89,6 +91,23 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id).count()
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.created_by_user_id == user_id,
|
||||
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.status == GenerationTaskStatus.PENDING.value)
|
||||
.count()
|
||||
)
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
@@ -129,5 +148,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.source_edit_plan_id = task.source_edit_plan_id or None
|
||||
model.asset_select_mode = task.asset_select_mode or ""
|
||||
model.batch_id = task.batch_id or ""
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -39,8 +39,8 @@ class UserModel(Base):
|
||||
class ProjectModel(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
owner_user_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
owner_user_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
|
||||
@@ -122,10 +122,11 @@ class EditTemplateModel(Base):
|
||||
|
||||
__tablename__ = "edit_templates"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
name = Column(String(120), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
template_type = Column(String(50), nullable=False, default="default", index=True)
|
||||
editing_mode = Column(String(20), nullable=False, default="one_take")
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
preview_url = Column(String(1000), nullable=False, default="")
|
||||
sort_weight = Column(Integer, nullable=False, default=0, index=True)
|
||||
@@ -142,15 +143,15 @@ class EditPlanModel(Base):
|
||||
|
||||
__tablename__ = "edit_plans"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
template_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
template_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="draft", index=True)
|
||||
total_duration = Column(Float, nullable=False, default=0.0)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
source_edit_plan_id = Column(String(32), nullable=True, index=True)
|
||||
project_id = Column(String(32), nullable=False, default="", index=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
|
||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -163,8 +164,8 @@ class TemplateClipConfigModel(Base):
|
||||
|
||||
__tablename__ = "template_clip_configs"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
template_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
template_id = Column(String(36), nullable=False, index=True)
|
||||
clip_type = Column(String(20), nullable=False, index=True)
|
||||
order = Column(Integer, nullable=False)
|
||||
min_duration = Column(Float, nullable=False, default=0.0)
|
||||
@@ -185,12 +186,12 @@ class EditPlanClipModel(Base):
|
||||
|
||||
__tablename__ = "edit_plan_clips"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
plan_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
plan_id = Column(String(36), nullable=False, index=True)
|
||||
clip_type = Column(String(20), nullable=False, index=True)
|
||||
order = Column(Integer, nullable=False)
|
||||
template_clip_config_id = Column(String(32), nullable=False, default="", index=True)
|
||||
asset_id = Column(String(32), nullable=False, default="", index=True)
|
||||
template_clip_config_id = Column(String(36), nullable=False, default="", index=True)
|
||||
asset_id = Column(String(36), nullable=False, default="", index=True)
|
||||
text_content = Column(Text, nullable=False, default="")
|
||||
start_time = Column(Float, nullable=False, default=0.0)
|
||||
duration = Column(Float, nullable=False, default=0.0)
|
||||
@@ -204,13 +205,13 @@ class EditPlanClipModel(Base):
|
||||
class IngestJobModel(Base):
|
||||
__tablename__ = "ingest_jobs"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
library_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=False, index=True)
|
||||
library_id = Column(String(36), nullable=False, index=True)
|
||||
storage_key = Column(String(255), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
result_asset_id = Column(String(32), nullable=False, default="")
|
||||
result_asset_id = Column(String(36), nullable=False, default="")
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -219,9 +220,9 @@ class IngestJobModel(Base):
|
||||
class ClassificationJobModel(Base):
|
||||
__tablename__ = "classification_jobs"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
asset_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=False, index=True)
|
||||
asset_id = Column(String(36), nullable=False, index=True)
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
classification = Column(String(50), nullable=False, default="")
|
||||
confidence = Column(Float, nullable=False, default=0.0)
|
||||
@@ -233,11 +234,11 @@ class ClassificationJobModel(Base):
|
||||
class GenerationTaskModel(Base):
|
||||
__tablename__ = "generation_tasks"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
project_id = Column(String(32), nullable=False, default="", index=True)
|
||||
strategy_id = Column(String(32), nullable=False, default="")
|
||||
asset_library_id = Column(String(32), nullable=False, default="", index=True)
|
||||
voice_library_id = Column(String(32), nullable=False, default="")
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
strategy_id = Column(String(36), nullable=False, default="")
|
||||
asset_library_id = Column(String(36), nullable=False, default="", index=True)
|
||||
voice_library_id = Column(String(36), nullable=False, default="")
|
||||
template_id = Column(String(36), nullable=False, default="", index=True)
|
||||
asset_ids = Column(JSON, nullable=False, default=list)
|
||||
title_ids = Column(JSON, nullable=False, default=list)
|
||||
@@ -251,20 +252,21 @@ class GenerationTaskModel(Base):
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
|
||||
source_edit_plan_id = Column(String(32), nullable=True, index=True)
|
||||
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
source_edit_plan_id = Column(String(36), nullable=True, index=True)
|
||||
asset_select_mode = Column(String(20), nullable=False, default="")
|
||||
batch_id = Column(String(32), nullable=False, default="", index=True)
|
||||
batch_id = Column(String(36), nullable=False, default="", index=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class GeneratedVideoModel(Base):
|
||||
__tablename__ = "generated_videos"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
generation_task_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=False, index=True)
|
||||
generation_task_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
# file_url: 完整可访问的 URL,用于客户端直接访问视频
|
||||
file_url = Column(String(1000), nullable=False)
|
||||
@@ -283,7 +285,7 @@ class GeneratedVideoModel(Base):
|
||||
updated_at = Column(DateTime, nullable=True)
|
||||
video_fingerprint = Column(Text, nullable=True)
|
||||
is_duplicate = Column(Boolean, nullable=False, default=False)
|
||||
duplicate_of = Column(String(32), nullable=True)
|
||||
duplicate_of = Column(String(36), nullable=True)
|
||||
|
||||
|
||||
class TitleLibraryModel(Base):
|
||||
@@ -450,8 +452,8 @@ class JobModel(Base):
|
||||
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=False, index=True)
|
||||
job_type = Column(String(30), nullable=False, index=True)
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
progress = Column(Float, nullable=False, default=0.0)
|
||||
@@ -462,8 +464,8 @@ class JobModel(Base):
|
||||
retry_count = Column(Integer, nullable=False, default=0)
|
||||
max_retries = Column(Integer, nullable=False, default=3)
|
||||
celery_task_id = Column(String(100), nullable=False, default="")
|
||||
source_id = Column(String(32), nullable=False, default="", index=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
|
||||
source_id = Column(String(36), nullable=False, default="", index=True)
|
||||
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
Regular → Executable
+316
-309
@@ -1,11 +1,13 @@
|
||||
"""CosyVoice 语音服务 — Phase 3.
|
||||
"""CosyVoice 语音服务 — 适配阿里云百炼 DashScope API.
|
||||
|
||||
封装阿里云 CosyVoice 语音合成 API,提供:
|
||||
封装阿里云百炼 CosyVoice 语音合成 API,提供:
|
||||
- 预置音色列表查询
|
||||
- 音色克隆(提交任务 + 轮询状态)
|
||||
- 语音合成(提交任务 + 轮询状态)
|
||||
- 音色克隆(提交 + 轮询状态)
|
||||
- 语音合成(同步非流式调用)
|
||||
|
||||
API 文档: https://help.aliyun.com/zh/model-studio/cosyvoice
|
||||
API 文档:
|
||||
- 音色克隆: https://help.aliyun.com/document_detail/3027318.html
|
||||
- 语音合成: https://help.aliyun.com/zh/model-studio/cosyvoice-tts-http-api
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,33 +62,34 @@ class SynthesizeResult:
|
||||
|
||||
|
||||
class CosyVoiceService:
|
||||
"""CosyVoice 语音服务。
|
||||
"""CosyVoice 语音服务.
|
||||
|
||||
封装阿里云 CosyVoice API,提供音色克隆和语音合成功能。
|
||||
支持同步和异步两种模式:
|
||||
- 同步:API 直接返回结果
|
||||
- 异步:API 返回 task_id,需要轮询状态
|
||||
封装阿里云百炼 CosyVoice API,提供音色克隆和语音合成功能.
|
||||
|
||||
接口总览:
|
||||
- 音色克隆: POST /services/audio/tts/customization (model=voice-enrollment)
|
||||
- action=create_voice: 创建克隆音色,返回 voice_id(状态 DEPLOYING)
|
||||
- action=query_voice: 查询音色状态(DEPLOYING / OK / UNDEPLOYED)
|
||||
- 语音合成: POST /services/audio/tts/SpeechSynthesizer (model=cosyvoice-v3-flash)
|
||||
- 非流式: 同步返回音频 URL
|
||||
|
||||
使用示例:
|
||||
service = CosyVoiceService(
|
||||
api_key="your-api-key",
|
||||
base_url="https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio",
|
||||
model="cosyvoice-v1",
|
||||
base_url="https://dashscope.aliyuncs.com/api/v1",
|
||||
model="cosyvoice-v3-flash",
|
||||
)
|
||||
|
||||
# 获取预置音色
|
||||
voices = service.list_preset_voices()
|
||||
|
||||
# 音色克隆
|
||||
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
|
||||
|
||||
# 语音合成
|
||||
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun")
|
||||
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3")
|
||||
"""
|
||||
|
||||
# 轮询配置
|
||||
POLL_INTERVAL = 2.0 # 秒
|
||||
MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(2分钟)
|
||||
# 音色状态轮询配置
|
||||
CLONE_POLL_INTERVAL = 5.0 # 秒
|
||||
CLONE_MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(5分钟)
|
||||
|
||||
# 重试配置
|
||||
MAX_RETRIES = 3
|
||||
@@ -97,27 +100,70 @@ class CosyVoiceService:
|
||||
api_key: str = "",
|
||||
base_url: str = "",
|
||||
model: str = "",
|
||||
clone_model: str = "",
|
||||
http_client: Optional[httpx.Client] = None,
|
||||
audio_url_signer: Optional[callable] = None,
|
||||
) -> None:
|
||||
"""初始化 CosyVoice 服务。
|
||||
"""初始化 CosyVoice 服务.
|
||||
|
||||
Args:
|
||||
api_key: CosyVoice API Key,为空时从配置读取
|
||||
base_url: CosyVoice API Base URL,为空时从配置读取
|
||||
model: CosyVoice 模型名称,为空时从配置读取
|
||||
api_key: DashScope API Key,为空时从配置读取
|
||||
base_url: DashScope API Base URL,为空时从配置读取
|
||||
model: 语音合成模型名称,为空时从配置读取
|
||||
clone_model: 音色克隆模型名称,为空时从配置读取
|
||||
http_client: 可选的 HTTP 客户端(用于测试注入)
|
||||
audio_url_signer: 可选的音频URL预签名函数,签名式 fn(url) -> str.
|
||||
用于私有 bucket 下,将裸 URL 转为预签名 URL,
|
||||
确保 CosyVoice 服务器能下载参考音频.
|
||||
"""
|
||||
settings = get_shared_settings()
|
||||
|
||||
self._api_key = api_key or settings.cosyvoice_api_key
|
||||
self._base_url = base_url or settings.cosyvoice_base_url
|
||||
self._model = model or settings.cosyvoice_model
|
||||
self._clone_model = clone_model or getattr(settings, "cosyvoice_clone_model", "voice-enrollment")
|
||||
self._audio_url_signer = audio_url_signer
|
||||
|
||||
# base_url 规范化:去掉末尾的路径残留(兼容旧版配置)
|
||||
# 旧版 .env 模板中 base_url 包含 /services/aigc/text2audio 完整路径,
|
||||
# 新版只需 /api/v1,具体路径由代码拼接。这里自动修正,避免配置滞后导致418。
|
||||
if "/services/aigc/text2audio" in self._base_url:
|
||||
old_url = self._base_url
|
||||
# 截取到 /api/v1 为止
|
||||
idx = self._base_url.find("/api/v1")
|
||||
if idx >= 0:
|
||||
self._base_url = self._base_url[: idx + len("/api/v1")]
|
||||
logger.warning(
|
||||
"[CosyVoice Config] base_url包含旧版text2audio路径,已自动修正: " "%s -> %s",
|
||||
old_url,
|
||||
self._base_url,
|
||||
)
|
||||
|
||||
self._client = http_client or httpx.Client(
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
self._owns_client = http_client is None
|
||||
|
||||
# 启动时打印配置(脱敏),方便排查环境变量覆盖问题
|
||||
if self._owns_client:
|
||||
masked_key = ""
|
||||
if self._api_key:
|
||||
if len(self._api_key) > 8:
|
||||
masked_key = f"{self._api_key[:4]}...{self._api_key[-4:]}"
|
||||
else:
|
||||
masked_key = "***"
|
||||
logger.info(
|
||||
"[CosyVoice Config] 初始化配置: "
|
||||
"model=%s, base_url=%s, default_voice=%s, "
|
||||
"sample_rate=%d, format=%s, api_key=%s",
|
||||
self._model,
|
||||
self._base_url,
|
||||
getattr(settings, "cosyvoice_voice", "(unset)"),
|
||||
settings.cosyvoice_sample_rate,
|
||||
settings.cosyvoice_format,
|
||||
masked_key or "(empty)",
|
||||
)
|
||||
|
||||
def __enter__(self) -> CosyVoiceService:
|
||||
return self
|
||||
|
||||
@@ -132,7 +178,7 @@ class CosyVoiceService:
|
||||
# ── 预置音色 ─────────────────────────────────────────
|
||||
|
||||
def list_preset_voices(self) -> list[PresetVoice]:
|
||||
"""获取预置音色列表。
|
||||
"""获取预置音色列表.
|
||||
|
||||
Returns:
|
||||
预置音色列表
|
||||
@@ -146,20 +192,22 @@ class CosyVoiceService:
|
||||
audio_url: str,
|
||||
voice_name: str = "",
|
||||
language: str = "zh-CN",
|
||||
target_model: str = "",
|
||||
) -> dict:
|
||||
"""提交音色克隆任务(非阻塞)。
|
||||
"""提交音色克隆任务(非阻塞).
|
||||
|
||||
只提交任务到 CosyVoice API,不轮询结果。
|
||||
返回的 dict 包含 task_id(异步)或 voice_id(同步)。
|
||||
调用百炼 voice-enrollment API 创建克隆音色.
|
||||
创建后音色状态为 DEPLOYING,需通过 query_voice_status 轮询直到 OK.
|
||||
|
||||
Args:
|
||||
audio_url: 参考音频 URL
|
||||
voice_name: 音色名称(可选)
|
||||
language: 语言代码
|
||||
audio_url: 参考音频 URL(必须公网可访问)
|
||||
voice_name: 音色名称前缀(字母数字,最多10字符)
|
||||
language: 语言代码(zh-CN 会转换为 zh)
|
||||
target_model: 目标合成模型,默认使用当前 model
|
||||
|
||||
Returns:
|
||||
dict: {"task_id": str, "voice_id": str, "request_id": str}
|
||||
task_id 和 voice_id 至少有一个非空
|
||||
dict: {"voice_id": str, "status": str, "request_id": str}
|
||||
voice_id 非空,status 通常为 DEPLOYING
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: API 调用失败
|
||||
@@ -171,48 +219,67 @@ class CosyVoiceService:
|
||||
if not self._api_key:
|
||||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||||
|
||||
# voice_name 作为 prefix,限制字母数字,最多10字符
|
||||
# 不符合要求的做清洗
|
||||
prefix = self._sanitize_prefix(voice_name) if voice_name else "clone"
|
||||
|
||||
# 语言转换:zh-CN → zh,保留 ISO 639-1 格式
|
||||
lang_code = language.split("-")[0].lower() if language else "zh"
|
||||
|
||||
target = target_model or self._model
|
||||
|
||||
# 如果配置了 audio_url_signer,对音频URL做预签名
|
||||
# (私有 bucket 下 CosyVoice 服务器无法直接访问裸 URL)
|
||||
signed_audio_url = audio_url
|
||||
if self._audio_url_signer:
|
||||
try:
|
||||
signed_audio_url = self._audio_url_signer(audio_url)
|
||||
logger.info("音频URL已预签名: original=%s signed_prefix=%s", audio_url[:80], signed_audio_url[:80])
|
||||
except Exception as e:
|
||||
logger.warning("音频URL预签名失败,使用原始URL: %s", e)
|
||||
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"model": self._clone_model,
|
||||
"input": {
|
||||
"audio_url": audio_url,
|
||||
},
|
||||
"parameters": {
|
||||
"language": language,
|
||||
"action": "create_voice",
|
||||
"target_model": target,
|
||||
"prefix": prefix,
|
||||
"url": signed_audio_url,
|
||||
"language_hints": [lang_code],
|
||||
},
|
||||
}
|
||||
if voice_name:
|
||||
payload["parameters"]["voice_name"] = voice_name
|
||||
|
||||
response = self._call_api(
|
||||
method="POST",
|
||||
path="/services/audio/voice-clone",
|
||||
path="/services/audio/tts/customization",
|
||||
json=payload,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
output = response.get("output", {})
|
||||
task_id = output.get("task_id", "")
|
||||
voice_id = output.get("voice_id", "")
|
||||
status = output.get("status", "DEPLOYING")
|
||||
request_id = response.get("request_id", "")
|
||||
|
||||
if not task_id and not voice_id:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
|
||||
if not voice_id:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 voice_id: {response}")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"voice_id": voice_id,
|
||||
"status": status,
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
def check_task_status(self, task_id: str) -> dict:
|
||||
"""查询克隆任务状态(单次查询,不轮询)。
|
||||
def query_voice_status(self, voice_id: str) -> dict:
|
||||
"""查询音色状态(单次查询,不轮询).
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
voice_id: 音色 ID
|
||||
|
||||
Returns:
|
||||
dict: {"status": str, "voice_id": str, "message": str}
|
||||
status 为 SUCCEEDED/FAILED/PENDING/RUNNING
|
||||
dict: {"status": str, "target_model": str, "gmt_create": str,
|
||||
"gmt_modified": str, "resource_link": str}
|
||||
status 为 DEPLOYING / OK / UNDEPLOYED
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: API 调用失败
|
||||
@@ -221,40 +288,92 @@ class CosyVoiceService:
|
||||
if not self._api_key:
|
||||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||||
|
||||
if not voice_id:
|
||||
raise ValueError("voice_id 不能为空")
|
||||
|
||||
payload = {
|
||||
"model": self._clone_model,
|
||||
"input": {
|
||||
"action": "query_voice",
|
||||
"voice_id": voice_id,
|
||||
},
|
||||
}
|
||||
|
||||
response = self._call_api(
|
||||
method="GET",
|
||||
path=f"/tasks/{task_id}",
|
||||
method="POST",
|
||||
path="/services/audio/tts/customization",
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
output = response.get("output", {})
|
||||
status = output.get("task_status", "").upper()
|
||||
voice_id = output.get("voice_id", "")
|
||||
message = output.get("message", "")
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"voice_id": voice_id,
|
||||
"message": message,
|
||||
"status": output.get("status", ""),
|
||||
"target_model": output.get("target_model", ""),
|
||||
"gmt_create": output.get("gmt_create", ""),
|
||||
"gmt_modified": output.get("gmt_modified", ""),
|
||||
"resource_link": output.get("resource_link", ""),
|
||||
}
|
||||
|
||||
def poll_clone_task(self, task_id: str, timeout: float = 300.0) -> dict:
|
||||
"""轮询音色克隆任务状态(公开方法)。
|
||||
def check_task_status(self, task_id: str) -> dict:
|
||||
"""查询克隆任务状态(兼容旧接口,实际用 voice_id 查询).
|
||||
|
||||
供 Celery 后台任务调用,轮询直到完成或超时。
|
||||
为了兼容旧代码,task_id 参数名保留,但实际传的是 voice_id.
|
||||
|
||||
Args:
|
||||
task_id: CosyVoice 任务 ID
|
||||
task_id: 音色 ID(兼容旧接口名)
|
||||
|
||||
Returns:
|
||||
dict: {"status": str, "voice_id": str, "message": str}
|
||||
"""
|
||||
result = self.query_voice_status(task_id)
|
||||
return {
|
||||
"status": result["status"],
|
||||
"voice_id": task_id,
|
||||
"message": "",
|
||||
}
|
||||
|
||||
def poll_clone_task(self, voice_id: str, timeout: float = 300.0) -> dict:
|
||||
"""轮询音色克隆状态直到完成或超时.
|
||||
|
||||
供 Celery 后台任务调用,轮询直到状态变为 OK 或 UNDEPLOYED.
|
||||
|
||||
Args:
|
||||
voice_id: 音色 ID
|
||||
timeout: 超时时间(秒),默认 300
|
||||
|
||||
Returns:
|
||||
dict: {"voice_id": str}
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: 任务失败
|
||||
CosyVoiceError: 任务失败(状态 UNDEPLOYED)
|
||||
CosyVoiceTimeoutError: 超时
|
||||
"""
|
||||
return self._poll_clone_task(task_id, timeout=timeout)
|
||||
start_time = time.time()
|
||||
attempts = 0
|
||||
|
||||
while attempts < self.CLONE_MAX_POLL_ATTEMPTS:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): voice_id={voice_id}")
|
||||
|
||||
result = self.query_voice_status(voice_id)
|
||||
status = result.get("status", "").upper()
|
||||
|
||||
if status == "OK":
|
||||
return {"voice_id": voice_id}
|
||||
elif status == "UNDEPLOYED":
|
||||
raise CosyVoiceError(f"音色克隆任务失败(审核未通过): voice_id={voice_id}")
|
||||
elif status in ("DEPLOYING", "PENDING", "PROCESSING", ""):
|
||||
# 继续轮询
|
||||
time.sleep(self.CLONE_POLL_INTERVAL)
|
||||
attempts += 1
|
||||
else:
|
||||
logger.warning("未知的音色状态: %s (voice_id=%s)", status, voice_id)
|
||||
time.sleep(self.CLONE_POLL_INTERVAL)
|
||||
attempts += 1
|
||||
|
||||
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: voice_id={voice_id}")
|
||||
|
||||
def clone_voice(
|
||||
self,
|
||||
@@ -262,122 +381,45 @@ class CosyVoiceService:
|
||||
voice_name: str = "",
|
||||
language: str = "zh-CN",
|
||||
timeout: float = 300.0,
|
||||
target_model: str = "",
|
||||
) -> CloneResult:
|
||||
"""克隆音色。
|
||||
"""克隆音色(阻塞,直到完成或超时).
|
||||
|
||||
提交音色克隆任务到 CosyVoice API,并轮询直到完成或超时。
|
||||
提交音色克隆到百炼 API,并轮询直到状态变为 OK 或超时.
|
||||
|
||||
Args:
|
||||
audio_url: 参考音频 URL
|
||||
voice_name: 音色名称(可选)
|
||||
audio_url: 参考音频 URL(必须公网可访问)
|
||||
voice_name: 音色名称前缀
|
||||
language: 语言代码
|
||||
timeout: 超时时间(秒)
|
||||
target_model: 目标合成模型
|
||||
|
||||
Returns:
|
||||
CloneResult: 克隆结果,包含 voice_id
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: API 调用失败
|
||||
CosyVoiceError: API 调用失败或克隆失败
|
||||
CosyVoiceTimeoutError: 超时
|
||||
CosyVoiceAuthError: 认证失败
|
||||
ValueError: 参数无效
|
||||
"""
|
||||
if not audio_url:
|
||||
raise ValueError("audio_url 不能为空")
|
||||
if not self._api_key:
|
||||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||||
|
||||
# 构建请求
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"input": {
|
||||
"audio_url": audio_url,
|
||||
},
|
||||
"parameters": {
|
||||
"language": language,
|
||||
},
|
||||
}
|
||||
if voice_name:
|
||||
payload["parameters"]["voice_name"] = voice_name
|
||||
|
||||
# 调用 API
|
||||
response = self._call_api(
|
||||
method="POST",
|
||||
path="/services/audio/voice-clone",
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
submit_result = self.submit_clone_task(
|
||||
audio_url=audio_url,
|
||||
voice_name=voice_name,
|
||||
language=language,
|
||||
target_model=target_model,
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
output = response.get("output", {})
|
||||
voice_id = submit_result["voice_id"]
|
||||
request_id = submit_result["request_id"]
|
||||
|
||||
# 检查是否有 task_id(异步模式)
|
||||
task_id = output.get("task_id")
|
||||
voice_id = output.get("voice_id")
|
||||
# 如果创建时已经是 OK 状态,直接返回
|
||||
if submit_result.get("status", "").upper() == "OK":
|
||||
return CloneResult(voice_id=voice_id, request_id=request_id)
|
||||
|
||||
if task_id:
|
||||
# 异步模式:轮询任务状态
|
||||
result = self._poll_clone_task(task_id, timeout)
|
||||
return CloneResult(
|
||||
voice_id=result["voice_id"],
|
||||
request_id=response.get("request_id", ""),
|
||||
)
|
||||
elif voice_id:
|
||||
# 同步模式:直接返回结果
|
||||
return CloneResult(
|
||||
voice_id=voice_id,
|
||||
request_id=response.get("request_id", ""),
|
||||
)
|
||||
else:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
|
||||
|
||||
def _poll_clone_task(self, task_id: str, timeout: float) -> dict:
|
||||
"""轮询音色克隆任务状态。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
任务结果字典
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: 任务失败
|
||||
CosyVoiceTimeoutError: 超时
|
||||
"""
|
||||
start_time = time.time()
|
||||
attempts = 0
|
||||
|
||||
while attempts < self.MAX_POLL_ATTEMPTS:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): task_id={task_id}")
|
||||
|
||||
response = self._call_api(
|
||||
method="GET",
|
||||
path=f"/tasks/{task_id}",
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
output = response.get("output", {})
|
||||
status = output.get("task_status", "").upper()
|
||||
|
||||
if status == "SUCCEEDED":
|
||||
voice_id = output.get("voice_id", "")
|
||||
if not voice_id:
|
||||
raise CosyVoiceError(f"音色克隆任务成功但未返回 voice_id: {response}")
|
||||
return {"voice_id": voice_id}
|
||||
elif status == "FAILED":
|
||||
error_msg = output.get("message", "未知错误")
|
||||
raise CosyVoiceError(f"音色克隆任务失败: {error_msg}")
|
||||
elif status in ("PENDING", "RUNNING"):
|
||||
# 继续轮询
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
attempts += 1
|
||||
else:
|
||||
raise CosyVoiceError(f"未知的任务状态: {status}")
|
||||
|
||||
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: task_id={task_id}")
|
||||
# 否则轮询
|
||||
result = self.poll_clone_task(voice_id, timeout=timeout)
|
||||
return CloneResult(voice_id=result["voice_id"], request_id=request_id)
|
||||
|
||||
# ── 语音合成 ─────────────────────────────────────────
|
||||
|
||||
@@ -388,11 +430,12 @@ class CosyVoiceService:
|
||||
sample_rate: int = 0,
|
||||
format: str = "",
|
||||
speed: float = 1.0,
|
||||
volume: int = 50,
|
||||
) -> dict:
|
||||
"""提交语音合成任务(非阻塞)。
|
||||
"""提交语音合成任务(同步非流式,直接返回结果).
|
||||
|
||||
只提交任务到 CosyVoice API,不轮询结果。
|
||||
返回的 dict 包含 task_id(异步)或 audio_url(同步)。
|
||||
CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
||||
调用后直接返回音频 URL. 此方法保持与旧接口兼容.
|
||||
|
||||
Args:
|
||||
text: 要合成的文本
|
||||
@@ -400,10 +443,11 @@ class CosyVoiceService:
|
||||
sample_rate: 采样率(Hz),0 表示使用配置默认值
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||||
volume: 音量(0-100),默认 50
|
||||
|
||||
Returns:
|
||||
dict: {"task_id": str, "audio_url": str, "request_id": str}
|
||||
task_id 和 audio_url 至少有一个非空
|
||||
dict: {"audio_url": str, "request_id": str,
|
||||
"duration": float, "file_size": int}
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: API 调用失败
|
||||
@@ -423,55 +467,47 @@ class CosyVoiceService:
|
||||
"model": self._model,
|
||||
"input": {
|
||||
"text": text,
|
||||
},
|
||||
"parameters": {
|
||||
"voice": voice_id,
|
||||
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
|
||||
"format": format or settings.cosyvoice_format,
|
||||
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
|
||||
"rate": speed,
|
||||
"volume": volume,
|
||||
},
|
||||
}
|
||||
|
||||
response = self._call_api(
|
||||
method="POST",
|
||||
path="/services/aigc/text2audio/generation",
|
||||
path="/services/audio/tts/SpeechSynthesizer",
|
||||
json=payload,
|
||||
timeout=60.0,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
output = response.get("output", {})
|
||||
task_id = output.get("task_id", "")
|
||||
audio_url = output.get("audio_url", "")
|
||||
audio = output.get("audio", {})
|
||||
audio_url = audio.get("url", "")
|
||||
request_id = response.get("request_id", "")
|
||||
|
||||
if not task_id and not audio_url:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 audio_url: {response}")
|
||||
if not audio_url:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
||||
"audio_url": audio_url,
|
||||
"duration": output.get("duration", 0.0),
|
||||
"file_size": output.get("file_size", 0),
|
||||
"duration": 0.0, # 同步接口不返回 duration
|
||||
"file_size": 0, # 同步接口不返回 file_size
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||||
"""轮询语音合成任务状态(公开方法)。
|
||||
"""轮询合成任务(同步接口无需轮询,保留兼容).
|
||||
|
||||
供 Celery 后台任务调用,轮询直到完成或超时。
|
||||
|
||||
Args:
|
||||
task_id: CosyVoice 任务 ID
|
||||
timeout: 超时时间(秒),默认 120
|
||||
|
||||
Returns:
|
||||
dict: {"audio_url": str, "duration": float, "file_size": int}
|
||||
CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
||||
此方法仅为保持接口兼容,实际调用时 task_id 应该为空.
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: 任务失败
|
||||
CosyVoiceTimeoutError: 超时
|
||||
CosyVoiceError: 同步接口无需轮询
|
||||
"""
|
||||
return self._poll_synthesize_task(task_id, timeout=timeout)
|
||||
raise CosyVoiceError("CosyVoice 非流式合成接口是同步的,无需轮询. " "请直接使用 submit_synthesize_task().")
|
||||
|
||||
def synthesize_speech(
|
||||
self,
|
||||
@@ -480,11 +516,13 @@ class CosyVoiceService:
|
||||
sample_rate: int = 0,
|
||||
format: str = "",
|
||||
speed: float = 1.0,
|
||||
volume: int = 50,
|
||||
timeout: float = 120.0,
|
||||
) -> SynthesizeResult:
|
||||
"""语音合成。
|
||||
"""语音合成(同步非流式).
|
||||
|
||||
提交语音合成任务到 CosyVoice API,并轮询直到完成或超时。
|
||||
调用百炼 CosyVoice SpeechSynthesizer 非流式接口,
|
||||
直接返回合成音频 URL.
|
||||
|
||||
Args:
|
||||
text: 要合成的文本
|
||||
@@ -492,129 +530,53 @@ class CosyVoiceService:
|
||||
sample_rate: 采样率(Hz),0 表示使用配置默认值
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||||
timeout: 超时时间(秒)
|
||||
volume: 音量(0-100),默认 50
|
||||
timeout: 超时时间(秒),保留参数兼容
|
||||
|
||||
Returns:
|
||||
SynthesizeResult: 合成结果,包含 audio_url
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: API 调用失败
|
||||
CosyVoiceTimeoutError: 超时
|
||||
CosyVoiceAuthError: 认证失败
|
||||
ValueError: 参数无效
|
||||
"""
|
||||
if not text:
|
||||
raise ValueError("text 不能为空")
|
||||
if not voice_id:
|
||||
raise ValueError("voice_id 不能为空")
|
||||
if not self._api_key:
|
||||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||||
|
||||
settings = get_shared_settings()
|
||||
|
||||
# 构建请求
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"input": {
|
||||
"text": text,
|
||||
},
|
||||
"parameters": {
|
||||
"voice": voice_id,
|
||||
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
|
||||
"format": format or settings.cosyvoice_format,
|
||||
"rate": speed,
|
||||
},
|
||||
}
|
||||
|
||||
# 调用 API
|
||||
response = self._call_api(
|
||||
method="POST",
|
||||
path="/services/aigc/text2audio/generation",
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
result = self.submit_synthesize_task(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
sample_rate=sample_rate,
|
||||
format=format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
output = response.get("output", {})
|
||||
|
||||
# 检查是否有 task_id(异步模式)
|
||||
task_id = output.get("task_id")
|
||||
audio_url = output.get("audio_url")
|
||||
|
||||
if task_id:
|
||||
# 异步模式:轮询任务状态
|
||||
result = self._poll_synthesize_task(task_id, timeout)
|
||||
return SynthesizeResult(
|
||||
audio_url=result["audio_url"],
|
||||
duration=result.get("duration", 0.0),
|
||||
file_size=result.get("file_size", 0),
|
||||
request_id=response.get("request_id", ""),
|
||||
)
|
||||
elif audio_url:
|
||||
# 同步模式:直接返回结果
|
||||
return SynthesizeResult(
|
||||
audio_url=audio_url,
|
||||
duration=output.get("duration", 0.0),
|
||||
file_size=output.get("file_size", 0),
|
||||
request_id=response.get("request_id", ""),
|
||||
)
|
||||
else:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url 或 task_id: {response}")
|
||||
|
||||
def _poll_synthesize_task(self, task_id: str, timeout: float) -> dict:
|
||||
"""轮询语音合成任务状态。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
任务结果字典
|
||||
|
||||
Raises:
|
||||
CosyVoiceError: 任务失败
|
||||
CosyVoiceTimeoutError: 超时
|
||||
"""
|
||||
start_time = time.time()
|
||||
attempts = 0
|
||||
|
||||
while attempts < self.MAX_POLL_ATTEMPTS:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise CosyVoiceTimeoutError(f"语音合成任务超时({timeout}秒): task_id={task_id}")
|
||||
|
||||
response = self._call_api(
|
||||
method="GET",
|
||||
path=f"/tasks/{task_id}",
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
output = response.get("output", {})
|
||||
status = output.get("task_status", "").upper()
|
||||
|
||||
if status == "SUCCEEDED":
|
||||
audio_url = output.get("audio_url", "")
|
||||
if not audio_url:
|
||||
raise CosyVoiceError(f"语音合成任务成功但未返回 audio_url: {response}")
|
||||
return {
|
||||
"audio_url": audio_url,
|
||||
"duration": output.get("duration", 0.0),
|
||||
"file_size": output.get("file_size", 0),
|
||||
}
|
||||
elif status == "FAILED":
|
||||
error_msg = output.get("message", "未知错误")
|
||||
raise CosyVoiceError(f"语音合成任务失败: {error_msg}")
|
||||
elif status in ("PENDING", "RUNNING"):
|
||||
# 继续轮询
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
attempts += 1
|
||||
else:
|
||||
raise CosyVoiceError(f"未知的任务状态: {status}")
|
||||
|
||||
raise CosyVoiceTimeoutError(f"语音合成任务轮询次数超限: task_id={task_id}")
|
||||
return SynthesizeResult(
|
||||
audio_url=result["audio_url"],
|
||||
duration=result.get("duration", 0.0),
|
||||
file_size=result.get("file_size", 0),
|
||||
request_id=result.get("request_id", ""),
|
||||
)
|
||||
|
||||
# ── 内部方法 ─────────────────────────────────────────
|
||||
|
||||
def _sanitize_prefix(self, name: str) -> str:
|
||||
"""清洗音色名称为合法的 prefix(字母数字,最多10字符).
|
||||
|
||||
Args:
|
||||
name: 原始音色名称
|
||||
|
||||
Returns:
|
||||
清洗后的 prefix
|
||||
"""
|
||||
# 只保留字母和数字
|
||||
cleaned = "".join(c for c in name if c.isalnum())
|
||||
# 最多10字符
|
||||
cleaned = cleaned[:10]
|
||||
# 如果清洗后为空,用默认值
|
||||
if not cleaned:
|
||||
cleaned = "clone"
|
||||
return cleaned
|
||||
|
||||
def _call_api(
|
||||
self,
|
||||
method: str,
|
||||
@@ -622,13 +584,13 @@ class CosyVoiceService:
|
||||
json: Optional[dict] = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""调用 CosyVoice API。
|
||||
"""调用 DashScope API.
|
||||
|
||||
支持重试和错误处理。
|
||||
支持重试和错误处理.
|
||||
|
||||
Args:
|
||||
method: HTTP 方法(GET/POST)
|
||||
path: API 路径
|
||||
path: API 路径(以 / 开头)
|
||||
json: 请求体
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
@@ -646,6 +608,22 @@ class CosyVoiceService:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# DEBUG: 打印完整请求信息,用于排查418错误
|
||||
import json as json_lib
|
||||
|
||||
safe_headers = {k: v for k, v in headers.items()}
|
||||
if "Authorization" in safe_headers:
|
||||
token = safe_headers["Authorization"]
|
||||
if len(token) > 20:
|
||||
safe_headers["Authorization"] = token[:13] + "..." + token[-4:]
|
||||
logger.info(
|
||||
"[CosyVoice Debug] 请求详情: " "method=%s, url=%s, headers=%s, body=%s",
|
||||
method,
|
||||
url,
|
||||
safe_headers,
|
||||
json_lib.dumps(json, ensure_ascii=False) if json else "None",
|
||||
)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
@@ -658,29 +636,58 @@ class CosyVoiceService:
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# DEBUG: 打印响应状态和完整响应体
|
||||
logger.info(
|
||||
"[CosyVoice Debug] 响应详情: " "status=%d, body=%s",
|
||||
response.status_code,
|
||||
response.text[:2000], # 最多2000字符,避免日志过大
|
||||
)
|
||||
|
||||
# 处理响应
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code in (401, 403):
|
||||
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
|
||||
elif response.status_code == 400:
|
||||
# 客户端错误,不重试
|
||||
body_text = response.text
|
||||
try:
|
||||
body = response.json()
|
||||
code = body.get("code", "")
|
||||
message = body.get("message", "")
|
||||
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
|
||||
except ValueError:
|
||||
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
|
||||
elif response.status_code >= 500:
|
||||
# 服务端错误,可重试
|
||||
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
|
||||
logger.warning(
|
||||
f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): " f"HTTP {response.status_code}"
|
||||
"CosyVoice API 失败 (尝试 %d/%d): HTTP %d",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
response.status_code,
|
||||
)
|
||||
else:
|
||||
# 客户端错误,不重试
|
||||
# 其他客户端错误,不重试
|
||||
raise CosyVoiceError(
|
||||
f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}"
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
last_error = CosyVoiceTimeoutError(f"请求超时: {e}")
|
||||
logger.warning(f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})")
|
||||
logger.warning(
|
||||
"CosyVoice API 超时 (尝试 %d/%d)",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
last_error = CosyVoiceError(f"请求错误: {e}")
|
||||
logger.warning(f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
|
||||
logger.warning(
|
||||
"CosyVoice API 请求错误 (尝试 %d/%d): %s",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
|
||||
# 指数退避
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
|
||||
Regular → Executable
+139
-61
@@ -14,7 +14,6 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Optional
|
||||
|
||||
@@ -190,10 +189,13 @@ class TTSWorkflowService:
|
||||
return job
|
||||
|
||||
def poll_and_process_synthesis(self, job_id: str, timeout: float = 120.0) -> TTSJob:
|
||||
"""轮询 CosyVoice 合成任务并处理结果。
|
||||
"""轮询/检查 CosyVoice 合成任务并处理结果.
|
||||
|
||||
从 job.metadata 获取 task_id,调用 CosyVoiceService.poll_synthesize_task()
|
||||
轮询状态,然后通过 process_synthesis_result / process_synthesis_failure 更新 job。
|
||||
新 CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
||||
start_synthesis 阶段通常已经完成. 此方法用于:
|
||||
1. job 已 completed → 直接返回(同步路径已处理)
|
||||
2. job 仍在 processing → 重新提交合成(兜底)
|
||||
3. 分段任务 → 检查分段状态
|
||||
|
||||
供 Celery 后台任务调用。
|
||||
"""
|
||||
@@ -201,22 +203,38 @@ class TTSWorkflowService:
|
||||
if job is None:
|
||||
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
|
||||
|
||||
# 已完成直接返回(同步路径在 start_synthesis 里已处理)
|
||||
if job.status == TTSJobStatus.COMPLETED.value:
|
||||
logger.info(f"TTS 任务已完成,跳过轮询: job_id={job_id}")
|
||||
return job
|
||||
|
||||
# 检查是否为分段合成任务
|
||||
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
|
||||
if segment_task_ids:
|
||||
return self._poll_segment_tasks(job)
|
||||
|
||||
# 单段模式:同步接口下通常不会走到这里,
|
||||
# 但如果因为异常导致仍在 processing,重新提交一次
|
||||
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if not task_id:
|
||||
raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata")
|
||||
|
||||
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
|
||||
return self.process_synthesis_result(
|
||||
job_id,
|
||||
audio_url=result["audio_url"],
|
||||
duration=result.get("duration", 0.0),
|
||||
file_size=result.get("file_size", 0),
|
||||
)
|
||||
# 新接口(同步):没有 task_id,重新合成
|
||||
if not task_id:
|
||||
logger.info(f"TTS 任务无 task_id,重新同步合成: job_id={job_id}")
|
||||
return self._resynthesize_and_complete(job)
|
||||
|
||||
# 旧接口遗留的 task_id,尝试轮询(兼容过渡)
|
||||
try:
|
||||
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
|
||||
return self.process_synthesis_result(
|
||||
job_id,
|
||||
audio_url=result["audio_url"],
|
||||
duration=result.get("duration", 0.0),
|
||||
file_size=result.get("file_size", 0),
|
||||
)
|
||||
except CosyVoiceError:
|
||||
# 旧接口轮询失败,重新同步合成
|
||||
logger.warning(f"旧 task_id 轮询失败,重新同步合成: job_id={job_id}, task_id={task_id}")
|
||||
return self._resynthesize_and_complete(job)
|
||||
|
||||
def process_synthesis_result(
|
||||
self,
|
||||
@@ -257,6 +275,40 @@ class TTSWorkflowService:
|
||||
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
|
||||
return job
|
||||
|
||||
def _resynthesize_and_complete(self, job: TTSJob) -> TTSJob:
|
||||
"""重新同步合成并完成任务(兜底路径).
|
||||
|
||||
当 poll_and_process_synthesis 发现 job 仍在 processing 且无 task_id 时,
|
||||
重新调用同步合成接口,转存 OSS 后标记完成。
|
||||
"""
|
||||
try:
|
||||
# 从 metadata 读取合成参数(兼容旧数据,无则用默认值)
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
|
||||
result = self.cosyvoice_service.submit_synthesize_task(
|
||||
text=job.input_text,
|
||||
voice_id=job.voice_id,
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
)
|
||||
audio_url = result.get("audio_url", "")
|
||||
if not audio_url:
|
||||
raise CosyVoiceError("重新合成未返回 audio_url")
|
||||
|
||||
return self.process_synthesis_result(
|
||||
job.id,
|
||||
audio_url=audio_url,
|
||||
duration=result.get("duration", 0.0),
|
||||
file_size=result.get("file_size", 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"重新同步合成失败: job_id={job.id}, error={e}")
|
||||
return self.process_synthesis_failure(job.id, str(e))
|
||||
|
||||
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
|
||||
"""处理合成失败结果。
|
||||
|
||||
@@ -429,69 +481,95 @@ class TTSWorkflowService:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
def _poll_segment_tasks(self, job: TTSJob) -> TTSJob:
|
||||
"""轮询所有分段异步任务,全部完成后合并音频。"""
|
||||
"""分段任务完成检查(适配新同步接口).
|
||||
|
||||
新 CosyVoice SpeechSynthesizer 非流式接口为同步接口,
|
||||
分段任务在提交时应已同步返回 audio_url。
|
||||
若历史任务处于 processing 且有 segment_task_ids 但缺少 audio_url,
|
||||
则对缺失分段重新同步合成,全部完成后合并音频。
|
||||
"""
|
||||
segment_task_ids: list[str] = (job.metadata or {}).get("segment_task_ids", [])
|
||||
segment_audio_urls: list[str] = (job.metadata or {}).get("segment_audio_urls", [])
|
||||
segment_count = len(segment_task_ids)
|
||||
|
||||
poll_start = time.monotonic()
|
||||
poll_timeout = 300.0 # 分段任务超时更长
|
||||
poll_interval = 2.0
|
||||
if segment_count == 0:
|
||||
logger.warning(f"分段任务无 task_id: job_id={job.id}")
|
||||
self._handle_segment_failure(job, "分段任务数据异常:无分段信息")
|
||||
return self.repository.get(job.id)
|
||||
|
||||
while time.monotonic() - poll_start < poll_timeout:
|
||||
all_done = True
|
||||
results: list[dict | None] = [None] * segment_count
|
||||
# 从 metadata 读取合成参数
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
|
||||
for idx, task_id in enumerate(segment_task_ids):
|
||||
# 已经有音频的分段跳过轮询
|
||||
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
|
||||
results[idx] = {
|
||||
"audio_url": segment_audio_urls[idx],
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
}
|
||||
continue
|
||||
# 分段文本(用于缺失段重新合成)
|
||||
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
|
||||
|
||||
try:
|
||||
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=poll_timeout)
|
||||
results[idx] = result
|
||||
except Exception as e:
|
||||
logger.error(f"分段任务轮询失败: job_id={job.id}, " f"segment={idx}, error={e}")
|
||||
self._handle_segment_failure(job, f"分段 {idx + 1} 轮询失败: {e}")
|
||||
return self.repository.get(job.id)
|
||||
results: list[dict | None] = [None] * segment_count
|
||||
|
||||
if results[idx] is None:
|
||||
all_done = False
|
||||
# 已有音频的分段直接用
|
||||
for idx in range(segment_count):
|
||||
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
|
||||
results[idx] = {
|
||||
"audio_url": segment_audio_urls[idx],
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
}
|
||||
|
||||
if all_done and all(r is not None for r in results):
|
||||
# 所有分段完成,下载合并
|
||||
try:
|
||||
merged_data, total_duration = self._download_and_merge_segments(results, job)
|
||||
# 找出缺失音频的分段索引
|
||||
missing_indices = [i for i in range(segment_count) if results[i] is None]
|
||||
|
||||
# 转存 OSS
|
||||
permanent_url, storage_key = self._upload_merged_to_oss(
|
||||
merged_data, job.user_id, job.id, job.format
|
||||
if missing_indices:
|
||||
logger.info(f"分段任务重新合成缺失段: job_id={job.id}, " f"缺失={len(missing_indices)}/{segment_count}")
|
||||
# 并发重新合成缺失分段
|
||||
max_workers = min(len(missing_indices), _MAX_SEGMENT_WORKERS)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_idx = {}
|
||||
for idx in missing_indices:
|
||||
segment_text = segments[idx] if idx < len(segments) else ""
|
||||
future = executor.submit(
|
||||
self.cosyvoice_service.submit_synthesize_task,
|
||||
text=segment_text,
|
||||
voice_id=job.voice_id,
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
volume=volume,
|
||||
)
|
||||
future_to_idx[future] = idx
|
||||
|
||||
job.mark_completed(
|
||||
output_audio_url=permanent_url,
|
||||
output_audio_key=storage_key,
|
||||
duration=total_duration,
|
||||
file_size=len(merged_data),
|
||||
)
|
||||
job = self.repository.update(job)
|
||||
logger.info(f"分段合成轮询完成: job_id={job.id}, " f"merged_size={len(merged_data)}")
|
||||
return job
|
||||
for future in as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
try:
|
||||
results[idx] = future.result()
|
||||
except Exception as e:
|
||||
logger.error(f"分段重新合成失败: job_id={job.id}, " f"segment={idx}, error={e}")
|
||||
self._handle_segment_failure(job, f"分段 {idx + 1} 重新合成失败: {e}")
|
||||
return self.repository.get(job.id)
|
||||
|
||||
except Exception as e:
|
||||
self._handle_segment_failure(job, f"分段合并失败: {e}")
|
||||
return self.repository.get(job.id)
|
||||
# 所有分段完成,下载合并
|
||||
if all(r is not None for r in results):
|
||||
try:
|
||||
merged_data, total_duration = self._download_and_merge_segments(results, job)
|
||||
|
||||
# 等待后重试
|
||||
time.sleep(poll_interval)
|
||||
permanent_url, storage_key = self._upload_merged_to_oss(merged_data, job.user_id, job.id, job.format)
|
||||
|
||||
# 超时
|
||||
self._handle_segment_failure(job, "分段合成轮询超时(300 秒)")
|
||||
job.mark_completed(
|
||||
output_audio_url=permanent_url,
|
||||
output_audio_key=storage_key,
|
||||
duration=total_duration,
|
||||
file_size=len(merged_data),
|
||||
)
|
||||
job = self.repository.update(job)
|
||||
logger.info(f"分段合成完成(重新合成路径): job_id={job.id}, " f"merged_size={len(merged_data)}")
|
||||
return job
|
||||
|
||||
except Exception as e:
|
||||
self._handle_segment_failure(job, f"分段合并失败: {e}")
|
||||
return self.repository.get(job.id)
|
||||
|
||||
# 理论上不会到这里(全部重新合成要么成功要么失败)
|
||||
self._handle_segment_failure(job, "分段合成结果不完整")
|
||||
return self.repository.get(job.id)
|
||||
|
||||
def _handle_segment_failure(self, job: TTSJob, error_message: str) -> None:
|
||||
|
||||
Regular → Executable
+12
-7
@@ -115,14 +115,16 @@ class VoiceCloneWorkflowService:
|
||||
language=language,
|
||||
)
|
||||
|
||||
# 4. 保存 task_id / voice_id 到 metadata
|
||||
# 4. 保存 voice_id / request_id 到 metadata
|
||||
# 注意:key 保留 cosyvoice_task_id 以兼容旧数据,实际存的是 voice_id
|
||||
task_metadata = dict(profile.metadata)
|
||||
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
|
||||
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
|
||||
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
|
||||
|
||||
# 如果 CosyVoice 同步返回了 voice_id,直接标记 ready
|
||||
# 如果 CosyVoice 直接返回了 OK 状态,直接标记 ready
|
||||
voice_id = submit_result.get("voice_id", "")
|
||||
if voice_id:
|
||||
status = submit_result.get("status", "").upper()
|
||||
if voice_id and status == "OK":
|
||||
profile.mark_ready(voice_id)
|
||||
profile.metadata = task_metadata
|
||||
profile = self.repository.update(profile)
|
||||
@@ -131,7 +133,9 @@ class VoiceCloneWorkflowService:
|
||||
|
||||
profile.metadata = task_metadata
|
||||
profile = self.repository.update(profile)
|
||||
logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"task_id={submit_result.get('task_id')}")
|
||||
logger.info(
|
||||
f"音色克隆任务已提交: profile_id={profile.id}, " f"voice_id={submit_result.get('voice_id')}"
|
||||
)
|
||||
|
||||
except (CosyVoiceError, CosyVoiceAuthError) as e:
|
||||
# CosyVoice 提交失败,标记为 failed
|
||||
@@ -248,11 +252,12 @@ class VoiceCloneWorkflowService:
|
||||
)
|
||||
|
||||
task_metadata = dict(profile.metadata)
|
||||
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
|
||||
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
|
||||
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
|
||||
|
||||
voice_id = submit_result.get("voice_id", "")
|
||||
if voice_id:
|
||||
status = submit_result.get("status", "").upper()
|
||||
if voice_id and status == "OK":
|
||||
profile.mark_ready(voice_id)
|
||||
profile.metadata = task_metadata
|
||||
profile = self.repository.update(profile)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -129,24 +130,29 @@ class EditPlanConfigSchema(BaseModel):
|
||||
|
||||
用于 API 层校验和默认值填充。所有子结构均可选,
|
||||
未传入时使用各自默认值。
|
||||
editing_mode 记录计划使用的剪辑模式。
|
||||
"""
|
||||
|
||||
cover: CoverConfig = Field(default_factory=CoverConfig, description="封面配置")
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置")
|
||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置")
|
||||
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式")
|
||||
|
||||
|
||||
class EditTemplateConfigSchema(BaseModel):
|
||||
"""EditTemplate.config 完整结构
|
||||
|
||||
模板级别的默认配置,创建计划时可作为初始值继承。
|
||||
editing_mode 指定模板对应的剪辑模式,transition_enabled 控制是否启用转场。
|
||||
"""
|
||||
|
||||
cover: CoverConfig = Field(default_factory=CoverConfig, description="封面默认配置")
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置")
|
||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置")
|
||||
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式")
|
||||
transition_enabled: bool = Field(default=True, description="是否启用转场")
|
||||
|
||||
|
||||
# ── 默认值常量 ────────────────────────────────────────────────────────────────
|
||||
@@ -183,9 +189,13 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
|
||||
"asset_id": "",
|
||||
"volume": 0.3,
|
||||
},
|
||||
"editing_mode": "one_take",
|
||||
}
|
||||
|
||||
DEFAULT_EDIT_TEMPLATE_CONFIG: dict = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
DEFAULT_EDIT_TEMPLATE_CONFIG: dict = {
|
||||
**DEFAULT_EDIT_PLAN_CONFIG,
|
||||
"transition_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
@@ -197,9 +207,7 @@ def normalize_plan_config(raw: dict | None) -> dict:
|
||||
用于创建/更新计划时确保 config 结构完整。
|
||||
"""
|
||||
if raw is None:
|
||||
return DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
|
||||
import copy
|
||||
return copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
base = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
|
||||
@@ -209,14 +217,43 @@ def normalize_plan_config(raw: dict | None) -> dict:
|
||||
base[section_key] = {}
|
||||
base[section_key].update(raw[section_key])
|
||||
|
||||
# editing_mode 顶层字段
|
||||
if "editing_mode" in raw and isinstance(raw["editing_mode"], str):
|
||||
base["editing_mode"] = raw["editing_mode"]
|
||||
|
||||
# 保留非标准字段(如 generation_task_id)
|
||||
for key, value in raw.items():
|
||||
if key not in ("cover", "title", "subtitle", "bgm"):
|
||||
if key not in ("cover", "title", "subtitle", "bgm", "editing_mode"):
|
||||
base[key] = value
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def normalize_template_config(raw: dict | None) -> dict:
|
||||
"""将模板原始 config dict 标准化。逻辑同 normalize_plan_config。"""
|
||||
return normalize_plan_config(raw)
|
||||
"""将模板原始 config dict 标准化。
|
||||
|
||||
在 plan config 基础上额外支持 transition_enabled 字段。
|
||||
"""
|
||||
if raw is None:
|
||||
return copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
|
||||
base = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
|
||||
|
||||
for section_key in ("cover", "title", "subtitle", "bgm"):
|
||||
if section_key in raw and isinstance(raw[section_key], dict):
|
||||
if section_key not in base:
|
||||
base[section_key] = {}
|
||||
base[section_key].update(raw[section_key])
|
||||
|
||||
# 顶层字段
|
||||
if "editing_mode" in raw and isinstance(raw["editing_mode"], str):
|
||||
base["editing_mode"] = raw["editing_mode"]
|
||||
if "transition_enabled" in raw and isinstance(raw["transition_enabled"], bool):
|
||||
base["transition_enabled"] = raw["transition_enabled"]
|
||||
|
||||
# 保留非标准字段
|
||||
for key, value in raw.items():
|
||||
if key not in ("cover", "title", "subtitle", "bgm", "editing_mode", "transition_enabled"):
|
||||
base[key] = value
|
||||
|
||||
return base
|
||||
|
||||
@@ -18,6 +18,8 @@ else:
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .editing_mode import EditingMode
|
||||
|
||||
|
||||
class EditTemplateStatus(StrEnum):
|
||||
"""模板状态"""
|
||||
@@ -26,18 +28,25 @@ class EditTemplateStatus(StrEnum):
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
_VALID_EDITING_MODES = {m.value for m in EditingMode}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditTemplate:
|
||||
"""Phase 8 剪辑模板实体
|
||||
|
||||
全局模板库中的模板,定义剪辑风格、配置参数和预览信息。
|
||||
不绑定到具体项目,可被多个 EditPlan 引用。
|
||||
|
||||
editing_mode 指定模板对应的剪辑模式(one_take / pip / voice_over / voice_pip),
|
||||
决定剪辑计划生成时的片段结构。
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
template_type: str = "default"
|
||||
editing_mode: str = EditingMode.ONE_TAKE.value
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
preview_url: str = ""
|
||||
sort_weight: int = 0
|
||||
@@ -52,6 +61,7 @@ class EditTemplate:
|
||||
*,
|
||||
description: str = "",
|
||||
template_type: str = "default",
|
||||
editing_mode: str = EditingMode.ONE_TAKE.value,
|
||||
config: dict[str, Any] | None = None,
|
||||
preview_url: str = "",
|
||||
sort_weight: int = 0,
|
||||
@@ -61,11 +71,17 @@ class EditTemplate:
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
clean_mode = editing_mode.strip() or EditingMode.ONE_TAKE.value
|
||||
if clean_mode not in _VALID_EDITING_MODES:
|
||||
raise ValueError(
|
||||
f"无效的 editing_mode: {clean_mode}," f"允许值: {', '.join(sorted(_VALID_EDITING_MODES))}"
|
||||
)
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
name=clean_name,
|
||||
description=description.strip(),
|
||||
template_type=template_type.strip() or "default",
|
||||
editing_mode=clean_mode,
|
||||
config=config or {},
|
||||
preview_url=preview_url.strip(),
|
||||
sort_weight=sort_weight,
|
||||
|
||||
Regular → Executable
+36
@@ -134,6 +134,25 @@ class AssetStatus(StrEnum):
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "AssetStatus":
|
||||
"""兼容历史数据,避免枚举转换失败导致500。
|
||||
|
||||
- uploaded → READY(早期版本用 uploaded 表示上传完成)
|
||||
- 其他未知值 → READY(兜底,不阻塞业务)
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("uploaded", "success", "ok", "done", "complete"):
|
||||
return cls.READY
|
||||
if normalized in ("upload", "uploading_start", "upload_start"):
|
||||
return cls.UPLOADING
|
||||
if normalized in ("failed", "fail", "err"):
|
||||
return cls.ERROR
|
||||
if normalized in ("process", "processing", "running", "run"):
|
||||
return cls.PROCESSING
|
||||
return cls.READY
|
||||
|
||||
|
||||
class ClassificationStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
@@ -141,6 +160,23 @@ class ClassificationStatus(StrEnum):
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "ClassificationStatus":
|
||||
"""兼容历史数据,避免枚举转换失败导致500。
|
||||
|
||||
- done → COMPLETED(早期版本用 done 表示完成)
|
||||
- 其他未知值 → PENDING(兜底,不阻塞业务)
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("done", "success", "finished", "complete"):
|
||||
return cls.COMPLETED
|
||||
if normalized in ("fail", "error", "err"):
|
||||
return cls.FAILED
|
||||
if normalized in ("process", "processing", "running", "run"):
|
||||
return cls.PROCESSING
|
||||
return cls.PENDING
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Asset:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -85,6 +86,7 @@ class GenerationTask:
|
||||
created_by_user_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
@@ -227,6 +229,43 @@ class GenerationTask:
|
||||
self.transition_to(GenerationTaskStatus.CANCELLED)
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── 日志辅助 ────────────────────────────────────────────────────────────
|
||||
|
||||
_MAX_LOGS = 200
|
||||
|
||||
def append_log(self, stage: str, message: str, level: str = "INFO", **kwargs) -> None:
|
||||
"""追加一条结构化日志到 logs 字段。
|
||||
|
||||
Args:
|
||||
stage: 阶段名称(如 "接收任务"、"下载素材"、"渲染")
|
||||
message: 日志消息
|
||||
level: 日志级别(INFO / WARN / ERROR)
|
||||
**kwargs: 额外字段(如 asset_id、duration 等)
|
||||
"""
|
||||
try:
|
||||
entries = json.loads(self.logs) if self.logs else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
entries = []
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"level": level,
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
**kwargs,
|
||||
}
|
||||
entries.append(entry)
|
||||
# 限制最多保留 _MAX_LOGS 条,防止字段过大
|
||||
if len(entries) > self._MAX_LOGS:
|
||||
entries = entries[-self._MAX_LOGS :]
|
||||
self.logs = json.dumps(entries, ensure_ascii=False)
|
||||
|
||||
def get_logs(self) -> list[dict]:
|
||||
"""解析 logs 字段为 list[dict]。"""
|
||||
try:
|
||||
return json.loads(self.logs) if self.logs else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def mark_pending_from_failed(self) -> None:
|
||||
"""从失败状态重置为待处理(用于重试)。
|
||||
|
||||
|
||||
Regular → Executable
+9
-9
@@ -16,7 +16,7 @@ class PresetVoice:
|
||||
"""预置音色定义。
|
||||
|
||||
Attributes:
|
||||
voice_id: CosyVoice 模型音色名(如 longxiaochun)
|
||||
voice_id: CosyVoice 模型音色名(如 longxiaochun_v3)
|
||||
name: 中文展示名
|
||||
description: 音色描述
|
||||
gender: 性别(male/female)
|
||||
@@ -49,7 +49,7 @@ class PresetVoice:
|
||||
# 预置音色列表(阿里云 CosyVoice 真实可用音色)
|
||||
PRESET_VOICES: list[PresetVoice] = [
|
||||
PresetVoice(
|
||||
voice_id="longxiaochun",
|
||||
voice_id="longxiaochun_v3",
|
||||
name="龙小淳",
|
||||
description="温柔女声,适合情感类内容",
|
||||
gender="female",
|
||||
@@ -57,7 +57,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["温柔", "女声", "情感"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longxiaoxia",
|
||||
voice_id="longxiaoxia_v3",
|
||||
name="龙小夏",
|
||||
description="知性女声,适合新闻播报",
|
||||
gender="female",
|
||||
@@ -65,7 +65,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["知性", "女声", "播报"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longxiaochen",
|
||||
voice_id="longxiaochen_v3",
|
||||
name="龙小晨",
|
||||
description="磁性男声,适合有声书",
|
||||
gender="male",
|
||||
@@ -73,7 +73,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["磁性", "男声", "有声书"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longyue",
|
||||
voice_id="longyue_v3",
|
||||
name="龙悦",
|
||||
description="甜美女声,适合广告配音",
|
||||
gender="female",
|
||||
@@ -81,7 +81,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["甜美", "女声", "广告"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longshu",
|
||||
voice_id="longshu_v3",
|
||||
name="龙书",
|
||||
description="沉稳男声,适合教育讲解",
|
||||
gender="male",
|
||||
@@ -89,7 +89,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["沉稳", "男声", "教育"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longjing",
|
||||
voice_id="longjing_v3",
|
||||
name="龙静",
|
||||
description="优雅女声,适合纪录片解说",
|
||||
gender="female",
|
||||
@@ -97,7 +97,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["优雅", "女声", "纪录片"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longbo",
|
||||
voice_id="longbo_v3",
|
||||
name="龙博",
|
||||
description="浑厚男声,适合科技类内容",
|
||||
gender="male",
|
||||
@@ -105,7 +105,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
tags=["浑厚", "男声", "科技"],
|
||||
),
|
||||
PresetVoice(
|
||||
voice_id="longtian",
|
||||
voice_id="longtian_v3",
|
||||
name="龙甜",
|
||||
description="活泼女声,适合短视频配音",
|
||||
gender="female",
|
||||
|
||||
Regular → Executable
+4
@@ -16,6 +16,10 @@ class GenerationTaskRepository(Protocol):
|
||||
|
||||
def count_by_user(self, user_id: str) -> int: ...
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int: ...
|
||||
|
||||
def count_pending_total(self) -> int: ...
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ...
|
||||
|
||||
Regular → Executable
+6
-4
@@ -29,13 +29,15 @@ class SharedSettings(BaseSettings):
|
||||
oss_access_key_secret: str = ""
|
||||
oss_bucket_name: str = "xiaoxia-autocut"
|
||||
|
||||
# CosyVoice (阿里云语音合成)
|
||||
# CosyVoice (阿里云百炼语音合成)
|
||||
cosyvoice_api_key: str = ""
|
||||
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio"
|
||||
cosyvoice_model: str = "cosyvoice-v1"
|
||||
cosyvoice_voice: str = "longxiaochun" # 默认音色
|
||||
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
|
||||
cosyvoice_model: str = "cosyvoice-v3-flash"
|
||||
cosyvoice_voice: str = "longxiaochun_v3" # 默认音色(v3 系列系统音色带 _v3 后缀)
|
||||
cosyvoice_sample_rate: int = 22050
|
||||
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
|
||||
# 音色克隆模型名(固定为 voice-enrollment)
|
||||
cosyvoice_clone_model: str = "voice-enrollment"
|
||||
|
||||
# Environment
|
||||
environment: str = "development"
|
||||
|
||||
Regular → Executable
+63
@@ -1,7 +1,70 @@
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ["py312"]
|
||||
extend-exclude = '''
|
||||
(
|
||||
\.git
|
||||
| \.cache
|
||||
| \.pytest_cache
|
||||
| \.mypy_cache
|
||||
| __pycache__
|
||||
| node_modules
|
||||
| \.venv
|
||||
| venv
|
||||
| build
|
||||
| dist
|
||||
| \.next
|
||||
| out
|
||||
| coverage
|
||||
)
|
||||
'''
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 120
|
||||
extend_skip_glob = [
|
||||
".git/**",
|
||||
".cache/**",
|
||||
".pytest_cache/**",
|
||||
".mypy_cache/**",
|
||||
"__pycache__/**",
|
||||
"node_modules/**",
|
||||
".venv/**",
|
||||
"venv/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"coverage/**",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["apps/api/app", "packages"]
|
||||
omit = [
|
||||
"*/migrations/*",
|
||||
"*/tests/*",
|
||||
"*/test_*.py",
|
||||
"*/site-packages/*",
|
||||
]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise NotImplementedError",
|
||||
"pass",
|
||||
"if TYPE_CHECKING:",
|
||||
"class .*Protocol",
|
||||
"@abstractmethod",
|
||||
"raise AssertionError",
|
||||
"raise RuntimeError",
|
||||
"if 0:",
|
||||
"if __debug__:",
|
||||
]
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
|
||||
[tool.coverage.xml]
|
||||
output = "coverage.xml"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
[pytest]
|
||||
pythonpath = . apps/api apps/worker
|
||||
testpaths = tests
|
||||
|
||||
# ===== 覆盖率配置 =====
|
||||
# 覆盖率统计范围(供 --cov 使用时的默认源)
|
||||
# 注意:addopts 不默认开启 --cov,避免影响本地开发调试
|
||||
# CI 中通过命令行参数显式开启:--cov=apps --cov-report=term --cov-report=xml --cov-fail-under=50
|
||||
|
||||
@@ -24,6 +24,9 @@ celery==5.4.0
|
||||
|
||||
# 对象存储
|
||||
oss2==2.18.4
|
||||
cryptography==46.0.5
|
||||
# 覆盖系统预装的旧版pyOpenSSL,与cryptography 46.0.5兼容
|
||||
pyOpenSSL==26.2.0
|
||||
|
||||
# HTTP 客户端
|
||||
httpx==0.27.2
|
||||
|
||||
@@ -30,7 +30,8 @@ fi
|
||||
# ---- Registry 配置 ----
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
CACHE_REGISTRY="${CACHE_REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
CACHE_TAG="${CACHE_TAG:-release}"
|
||||
# 主缓存 tag:develop 分支构建时写入,所有分支读取
|
||||
CACHE_TAG_PRIMARY="${CACHE_TAG:-develop}"
|
||||
|
||||
API_IMAGE="xiaoxia-saas-api:$VERSION"
|
||||
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
|
||||
@@ -45,6 +46,7 @@ REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$VERSION"
|
||||
|
||||
USE_CACHE=0
|
||||
USE_PUSH=0
|
||||
CACHE_WRITE=0
|
||||
|
||||
# 检查 buildx 和 Registry 认证
|
||||
if docker buildx version >/dev/null 2>&1; then
|
||||
@@ -54,9 +56,13 @@ if docker buildx version >/dev/null 2>&1; then
|
||||
docker buildx use default 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "=== Building API image ==="
|
||||
# ---- 缓存读写策略(按分支隔离)----
|
||||
# 默认只读不写,防止 feature 分支污染主缓存
|
||||
# 只有 develop/main 分支才写回缓存
|
||||
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
|
||||
-f infra/docker/api.Dockerfile \
|
||||
@@ -64,12 +70,59 @@ if [ "$USE_CACHE" -eq 1 ]; then
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
|
||||
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
|
||||
fi
|
||||
|
||||
build_with_cache() {
|
||||
# usage: build_with_cache <image_name> <dockerfile> <extra_args...>
|
||||
IMG_NAME="$1"
|
||||
DOCKERFILE="$2"
|
||||
shift 2
|
||||
EXTRA_ARGS="$*"
|
||||
|
||||
CACHE_FROM="type=registry,ref=${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY},ignore-error=true"
|
||||
|
||||
if [ "$CACHE_WRITE" -eq 1 ]; then
|
||||
CACHE_TO="type=registry,ref=${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY},mode=max"
|
||||
echo " cache: read+write from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
|
||||
else
|
||||
CACHE_TO=""
|
||||
echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
|
||||
fi
|
||||
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
if [ -n "$CACHE_TO" ]; then
|
||||
docker buildx build \
|
||||
$EXTRA_ARGS \
|
||||
--cache-from "$CACHE_FROM" \
|
||||
--cache-to "$CACHE_TO" \
|
||||
-f "$DOCKERFILE" \
|
||||
-t "$IMG_NAME:$VERSION" \
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker buildx build \
|
||||
$EXTRA_ARGS \
|
||||
--cache-from "$CACHE_FROM" \
|
||||
-f "$DOCKERFILE" \
|
||||
-t "$IMG_NAME:$VERSION" \
|
||||
--load \
|
||||
.
|
||||
fi
|
||||
else
|
||||
docker build --pull=false $EXTRA_ARGS -f "$DOCKERFILE" -t "$IMG_NAME:$VERSION" .
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Building API image ==="
|
||||
build_with_cache "api" "infra/docker/api.Dockerfile" \
|
||||
"--build-arg APP_VERSION=$VERSION"
|
||||
docker tag "$API_IMAGE" "$API_LATEST"
|
||||
|
||||
echo "=== Building Worker image ==="
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
|
||||
-f infra/docker/worker.Dockerfile \
|
||||
@@ -77,13 +130,20 @@ if [ "$USE_CACHE" -eq 1 ]; then
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
|
||||
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
|
||||
fi
|
||||
|
||||
echo "=== Building Web image (with buildx cache) ==="
|
||||
# 先构建前端产物
|
||||
# 先构建前端产物(使用持久化 npm 缓存卷)
|
||||
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
|
||||
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
|
||||
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
|
||||
echo " Created npm cache volume: $NPM_CACHE_VOLUME"
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc "npm ci && npm run build"
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""解析 coverage.xml 并输出覆盖率汇总。"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
THRESHOLD = int(os.environ.get("COVERAGE_THRESHOLD", 65)) # 行覆盖率门槛,百分比,可通过环境变量覆盖
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
tree = ET.parse("coverage.xml")
|
||||
except FileNotFoundError:
|
||||
print("coverage.xml 不存在,跳过汇总")
|
||||
return 0
|
||||
|
||||
root = tree.getroot()
|
||||
line_rate = float(root.get("line-rate", 0)) * 100
|
||||
branch_rate = float(root.get("branch-rate", 0)) * 100
|
||||
lines_covered = int(root.get("lines-covered", 0))
|
||||
lines_valid = int(root.get("lines-valid", 0))
|
||||
|
||||
print(f"行覆盖率: {line_rate:.2f}% ({lines_covered}/{lines_valid})")
|
||||
print(f"分支覆盖率: {branch_rate:.2f}%")
|
||||
print(f"门槛: {THRESHOLD}%")
|
||||
status = "PASS ✅" if line_rate >= THRESHOLD else "FAIL ❌"
|
||||
print(f"状态: {status}")
|
||||
|
||||
return 0 if line_rate >= THRESHOLD else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""发送 CI 失败通知到飞书/项目群 webhook。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> int:
|
||||
webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "")
|
||||
if not webhook:
|
||||
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||||
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||||
return 0
|
||||
|
||||
failed_job = os.environ.get("FAILED_JOB", "Unknown Job")
|
||||
branch = os.environ.get("GITHUB_REF_NAME", "unknown")
|
||||
commit = os.environ.get("GITHUB_SHA", "unknown")[:8]
|
||||
actor = os.environ.get("GITHUB_ACTOR", "unknown")
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "unknown")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "unknown")
|
||||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||||
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": "❌ CI 构建失败",
|
||||
},
|
||||
"status": "red",
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**任务**: {failed_job}\n"
|
||||
f"**分支**: {branch}\n"
|
||||
f"**提交**: {commit}\n"
|
||||
f"**提交者**: {actor}\n"
|
||||
f"**Run ID**: {run_id}"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看失败日志"},
|
||||
"url": run_url,
|
||||
"type": "danger",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
print("通知已发送")
|
||||
except Exception as e:
|
||||
print(f"通知发送失败: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""发送 CI 成功通知到飞书/项目群 webhook。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> int:
|
||||
webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "")
|
||||
if not webhook:
|
||||
print("未配置 CI_NOTIFY_WEBHOOK,跳过成功通知")
|
||||
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||||
return 0
|
||||
|
||||
success_job = os.environ.get("SUCCESS_JOB", "Unknown Job")
|
||||
branch = os.environ.get("GITHUB_REF_NAME", "unknown")
|
||||
commit = os.environ.get("GITHUB_SHA", "unknown")[:8]
|
||||
actor = os.environ.get("GITHUB_ACTOR", "unknown")
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "unknown")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "unknown")
|
||||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||||
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": "✅ CI 构建成功",
|
||||
},
|
||||
"status": "green",
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**任务**: {success_job}\n"
|
||||
f"**分支**: {branch}\n"
|
||||
f"**提交**: {commit}\n"
|
||||
f"**提交者**: {actor}\n"
|
||||
f"**Run ID**: {run_id}"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看构建详情"},
|
||||
"url": run_url,
|
||||
"type": "primary",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
print("成功通知已发送")
|
||||
except Exception as e:
|
||||
print(f"成功通知发送失败: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,6 +3,7 @@ max-line-length = 120
|
||||
extend-ignore = E203,W503,E501,E302,E402,E722,W291,W293,F401,F403,F405,F841
|
||||
exclude =
|
||||
.git,
|
||||
.cache,
|
||||
__pycache__,
|
||||
.venv,
|
||||
.venv-ci-root,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""四模式渲染集成测试.
|
||||
|
||||
验证 4 种剪辑模式(ONE_TAKE / PIP / VOICE_OVER / VOICE_PIP)通过
|
||||
_build_plan_and_clips_from_task + UnifiedRenderService 的完整渲染流程。
|
||||
|
||||
需要 ffmpeg 可用;CI 无 ffmpeg 时自动跳过。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
_resolve_layer_role,
|
||||
)
|
||||
from worker_app.tasks.generation import _build_plan_and_clips_from_task
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg"),
|
||||
reason="ffmpeg not available",
|
||||
)
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _generate_test_video(path: Path, duration: float = 3.0, color: str = "red") -> None:
|
||||
"""生成一个纯色测试视频。"""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c={color}:s=640x360:d={duration}:r=25",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(path),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
def _render_with_mode(
|
||||
mode: str,
|
||||
num_clips: int = 3,
|
||||
duration: float = 2.0,
|
||||
) -> tuple[RenderResult, Path]:
|
||||
"""用指定模式生成测试视频并渲染,返回 (result, work_dir)。
|
||||
|
||||
调用方负责清理 work_dir。
|
||||
"""
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="test_4mode_"))
|
||||
|
||||
# 生成测试视频素材
|
||||
colors = ["red", "green", "blue", "yellow", "purple"]
|
||||
downloaded_paths: list[Path] = []
|
||||
for i in range(num_clips):
|
||||
p = work_dir / f"test_{i:03d}.mp4"
|
||||
_generate_test_video(p, duration=duration, color=colors[i % len(colors)])
|
||||
downloaded_paths.append(p)
|
||||
|
||||
# 构建虚拟 plan + clips
|
||||
task_id = f"test_task_{mode}"
|
||||
plan, clips, asset_path_map = _build_plan_and_clips_from_task(
|
||||
task_id=task_id,
|
||||
downloaded_paths=downloaded_paths,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
# 渲染
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
output_fps=25,
|
||||
)
|
||||
result = service.render()
|
||||
return result, work_dir
|
||||
|
||||
|
||||
# ── 测试 _build_plan_and_clips_from_task ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildPlanAndClips:
|
||||
"""测试 4 种模式的虚拟 plan 构建。"""
|
||||
|
||||
def _make_paths(self, n: int) -> list[Path]:
|
||||
return [Path(f"/tmp/test_{i}.mp4") for i in range(n)]
|
||||
|
||||
def test_one_take_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t1", paths, "one_take")
|
||||
|
||||
assert plan.id == "t1"
|
||||
assert len(clips) == 3
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
assert len(asset_map) == 3
|
||||
|
||||
def test_pip_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t2", paths, "pip")
|
||||
|
||||
assert len(clips) == 3
|
||||
assert clips[0].clip_type == "main"
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
|
||||
def test_voice_over_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t3", paths, "voice_over")
|
||||
|
||||
assert len(clips) == 3
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
assert all(c.config.get("role") == "b_roll" for c in clips)
|
||||
|
||||
def test_voice_pip_mode(self):
|
||||
paths = self._make_paths(4)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t4", paths, "voice_pip")
|
||||
|
||||
assert len(clips) == 4
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
|
||||
def test_unknown_mode_defaults_to_one_take(self):
|
||||
paths = self._make_paths(2)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t5", paths, "unknown_mode")
|
||||
|
||||
assert len(clips) == 2
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
|
||||
def test_asset_path_map_keys_match_clip_asset_ids(self):
|
||||
paths = self._make_paths(3)
|
||||
_, clips, asset_map = _build_plan_and_clips_from_task("t6", paths, "one_take")
|
||||
|
||||
clip_asset_ids = {c.asset_id for c in clips}
|
||||
map_keys = set(asset_map.keys())
|
||||
assert clip_asset_ids == map_keys
|
||||
|
||||
|
||||
# ── 测试图层分组(4 模式) ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFourModeLayerGrouping:
|
||||
"""验证 4 种模式的 clip_type 分布经 _resolve_layer_role 后产生正确的图层。"""
|
||||
|
||||
def test_one_take_layers(self):
|
||||
"""ONE_TAKE: 3 main → 1 main layer。"""
|
||||
paths = [Path(f"/tmp/ot_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("ot", paths, "one_take")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"main"}
|
||||
|
||||
def test_pip_layers(self):
|
||||
"""PIP: 1 main + 2 overlay → main + overlay。"""
|
||||
paths = [Path(f"/tmp/pip_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("pip", paths, "pip")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"main", "overlay"}
|
||||
|
||||
def test_voice_over_layers(self):
|
||||
"""VOICE_OVER: 3 main(b_roll) → broll。"""
|
||||
paths = [Path(f"/tmp/vo_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("vo", paths, "voice_over")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"broll"}
|
||||
|
||||
def test_voice_pip_layers(self):
|
||||
"""VOICE_PIP: 1 bg + 1 corner_voice + 2 b_roll → 3 个图层。"""
|
||||
paths = [Path(f"/tmp/vpip_{i}.mp4") for i in range(4)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("vpip", paths, "voice_pip")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"background", "corner_voice", "broll"}
|
||||
|
||||
|
||||
# ── 端到端渲染测试(需要 ffmpeg) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndToEndRendering:
|
||||
"""4 种模式的完整渲染测试,验证输出文件存在且时长合理。"""
|
||||
|
||||
def test_one_take_render(self):
|
||||
result, work_dir = _render_with_mode("one_take", num_clips=2, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
assert result.width == 640
|
||||
assert result.height == 360
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def test_pip_render(self):
|
||||
result, work_dir = _render_with_mode("pip", num_clips=2, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def test_voice_over_render(self):
|
||||
result, work_dir = _render_with_mode("voice_over", num_clips=2, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
def test_voice_pip_render(self):
|
||||
result, work_dir = _render_with_mode("voice_pip", num_clips=3, duration=2.0)
|
||||
try:
|
||||
assert result.output_path.exists()
|
||||
assert result.file_size > 0
|
||||
assert result.duration > 0
|
||||
finally:
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""全链路集成测试.
|
||||
|
||||
验证 PlanGeneratorService → UnifiedRenderService → 查重 的端到端流程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from worker_app.tasks.generation import (
|
||||
OUTPUT_HEIGHT,
|
||||
OUTPUT_WIDTH,
|
||||
_build_plan_and_clips_from_task,
|
||||
_create_fallback_clip,
|
||||
_mux_audio_track,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg"),
|
||||
reason="ffmpeg not available",
|
||||
)
|
||||
|
||||
|
||||
def _generate_test_video(path: Path, duration: float = 3.0) -> None:
|
||||
"""生成一个测试视频。"""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=blue:s=640x360:d={duration}:r=25",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
def _generate_test_audio(path: Path, duration: float = 5.0) -> None:
|
||||
"""生成一个测试音频文件。"""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"sine=frequency=440:duration={duration}",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(path),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
# ── 测试 _create_fallback_clip ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackClip:
|
||||
"""测试 fallback 视频生成。"""
|
||||
|
||||
def test_fallback_clip_creates_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "fallback.mp4"
|
||||
_create_fallback_clip(output, "Test Fallback")
|
||||
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
|
||||
|
||||
# ── 测试 _mux_audio_track ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMuxAudioTrack:
|
||||
"""测试视频+音频混合。"""
|
||||
|
||||
def test_mux_audio_into_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video_path = Path(tmpdir) / "video.mp4"
|
||||
audio_path = Path(tmpdir) / "audio.aac"
|
||||
output_path = Path(tmpdir) / "output.mp4"
|
||||
|
||||
_generate_test_video(video_path, duration=3.0)
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
_mux_audio_track(video_path, str(audio_path), output_path)
|
||||
|
||||
assert output_path.exists()
|
||||
assert output_path.stat().st_size > 0
|
||||
|
||||
# 验证输出文件包含音频轨
|
||||
probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
|
||||
# 如果有音频流,输出非空
|
||||
assert result.stdout.strip() != "" or result.returncode == 0
|
||||
|
||||
|
||||
# ── 测试 PlanGenerator → UnifiedRenderService 全链路 ─────────────────────────
|
||||
|
||||
|
||||
class TestFullPipeline:
|
||||
"""验证从虚拟 plan 构建到渲染输出的完整流程。"""
|
||||
|
||||
def test_one_take_pipeline(self):
|
||||
"""ONE_TAKE 模式完整流程。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
work_dir = Path(tmpdir)
|
||||
|
||||
# 生成测试素材
|
||||
paths = []
|
||||
for i in range(3):
|
||||
p = work_dir / f"clip_{i}.mp4"
|
||||
_generate_test_video(p, duration=2.0)
|
||||
paths.append(p)
|
||||
|
||||
# 构建虚拟 plan
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("pipeline_test", paths, "one_take")
|
||||
|
||||
# 渲染
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
)
|
||||
result = service.render()
|
||||
|
||||
assert result.output_path.exists()
|
||||
assert result.duration > 0
|
||||
assert result.file_size > 0
|
||||
assert result.width == 640
|
||||
assert result.height == 360
|
||||
|
||||
def test_pipeline_with_audio_mux(self):
|
||||
"""渲染 + 混音后处理。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
work_dir = Path(tmpdir)
|
||||
|
||||
# 生成测试素材
|
||||
video_path = work_dir / "clip_0.mp4"
|
||||
_generate_test_video(video_path, duration=3.0)
|
||||
|
||||
# 构建虚拟 plan
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("audio_test", [video_path], "one_take")
|
||||
|
||||
# 渲染
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
)
|
||||
render_result = service.render()
|
||||
|
||||
# 混音
|
||||
audio_path = work_dir / "voice.aac"
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
final_path = work_dir / "final.mp4"
|
||||
_mux_audio_track(render_result.output_path, str(audio_path), final_path)
|
||||
|
||||
assert final_path.exists()
|
||||
assert final_path.stat().st_size > 0
|
||||
|
||||
def test_single_clip_pipeline(self):
|
||||
"""单 clip 渲染(无转场)。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
work_dir = Path(tmpdir)
|
||||
|
||||
video_path = work_dir / "single.mp4"
|
||||
_generate_test_video(video_path, duration=5.0)
|
||||
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("single_test", [video_path], "one_take")
|
||||
|
||||
service = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_map,
|
||||
work_dir=work_dir,
|
||||
output_width=640,
|
||||
output_height=360,
|
||||
)
|
||||
result = service.render()
|
||||
|
||||
assert result.output_path.exists()
|
||||
assert result.duration > 0
|
||||
|
||||
def test_dedup_helper_integration(self):
|
||||
"""验证 dedup_helpers.create_video_record_and_dedup 的导入和签名。"""
|
||||
# 只验证函数存在且签名正确(不实际调用,需要数据库)
|
||||
import inspect
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
sig = inspect.signature(create_video_record_and_dedup)
|
||||
params = set(sig.parameters.keys())
|
||||
expected = {
|
||||
"generation_task_id",
|
||||
"project_id",
|
||||
"batch_id",
|
||||
"file_url",
|
||||
"file_size",
|
||||
"duration",
|
||||
"video_path",
|
||||
"mode",
|
||||
"session",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
}
|
||||
assert expected.issubset(params), f"Missing params: {expected - params}"
|
||||
Regular → Executable
+22
-10
@@ -118,6 +118,18 @@ class StubGenerationTaskRepository:
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return len(
|
||||
[
|
||||
t
|
||||
for t in self._tasks.values()
|
||||
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
|
||||
]
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return len([t for t in self._tasks.values() if t.status == GenerationTaskStatus.PENDING])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
@@ -241,7 +253,7 @@ def client():
|
||||
class TestCreateGenerationTask:
|
||||
"""创建生成任务端点测试。"""
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_task_success(self, mock_celery, client):
|
||||
"""正常创建生成任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
@@ -270,7 +282,7 @@ class TestCreateGenerationTask:
|
||||
assert mock_celery.send_task.called
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_batch_tasks(self, mock_celery, client):
|
||||
"""批量创建多个生成任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
@@ -347,7 +359,7 @@ class TestListGenerationTasks:
|
||||
|
||||
def _create_task(self, client, task_suffix: str = "1"):
|
||||
"""辅助方法:创建一个生成任务。"""
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
@@ -368,7 +380,7 @@ class TestListGenerationTasks:
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_list_returns_user_tasks(self, mock_celery, client):
|
||||
"""返回当前用户的生成任务列表。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
@@ -406,7 +418,7 @@ class TestGetGenerationTask:
|
||||
"""获取生成任务详情端点测试。"""
|
||||
|
||||
def _create_task(self, client) -> str:
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
@@ -449,7 +461,7 @@ class TestListGenerationResults:
|
||||
"""列出生成结果端点测试。"""
|
||||
|
||||
def _create_task(self, client) -> str:
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
@@ -489,7 +501,7 @@ class TestRetryGenerationTask:
|
||||
|
||||
def _create_failed_task(self, client) -> str:
|
||||
"""创建一个失败状态的任务。"""
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
@@ -509,7 +521,7 @@ class TestRetryGenerationTask:
|
||||
# 让我们直接通过 retry 测试来验证
|
||||
return task_id
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_retry_failed_task(self, mock_celery, client):
|
||||
"""重试失败的任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
@@ -539,7 +551,7 @@ class TestRetryGenerationTask:
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
||||
"""重试已完成的任务返回 409。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
@@ -568,7 +580,7 @@ class TestRetryGenerationTask:
|
||||
class TestGenerationTaskFlow:
|
||||
"""生成任务完整流程集成测试。"""
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_list_detail_results_flow(self, mock_celery, client):
|
||||
"""测试创建 → 列表 → 详情 → 结果 完整流程。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
Regular → Executable
+14
-2
@@ -83,6 +83,18 @@ class StubGenerationTaskRepository:
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return len(
|
||||
[
|
||||
t
|
||||
for t in self._tasks.values()
|
||||
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
|
||||
]
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return len([t for t in self._tasks.values() if t.status == GenerationTaskStatus.PENDING])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
@@ -428,7 +440,7 @@ class TestRetryProjectTask:
|
||||
assert resp.status_code == 400
|
||||
assert "Unsupported" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_retry_failed_generation_task(self, mock_celery, client):
|
||||
"""重试失败的 generation 任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
@@ -581,7 +593,7 @@ class TestRetryProjectTask:
|
||||
class TestTaskCenterCrossEndpoint:
|
||||
"""任务中心跨端点集成测试。"""
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_list_then_retry_then_list(self, mock_celery, client):
|
||||
"""列出任务 → 重试失败任务 → 再列出验证新任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
Regular → Executable
+18
-17
@@ -32,7 +32,11 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
|
||||
from app.api.routes.voice_clones import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
from app.dependencies import (
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.voice_clone_profile import (
|
||||
@@ -226,7 +230,7 @@ def clone_repo():
|
||||
|
||||
@pytest.fixture
|
||||
def cosyvoice_service():
|
||||
return MockCosyVoiceService(async_mode=False) # 同步模式,简化测试
|
||||
return MockCosyVoiceService(async_mode=True) # 异步模式,匹配真实 CosyVoice API 行为
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -241,6 +245,7 @@ def client(clone_repo, cosyvoice_service):
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
|
||||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||||
test_app.dependency_overrides[get_audio_url_signer] = lambda: (lambda url: url)
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
@@ -256,7 +261,7 @@ class TestCreateVoiceClone:
|
||||
"""创建声音克隆端点测试。"""
|
||||
|
||||
def test_create_with_source_audio(self, client, cosyvoice_service):
|
||||
"""提供源音频时创建克隆,同步模式下直接 ready。"""
|
||||
"""提供源音频时创建克隆,异步提交后状态为 processing。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
@@ -277,9 +282,9 @@ class TestCreateVoiceClone:
|
||||
assert "id" in data
|
||||
assert len(data["id"]) > 0
|
||||
|
||||
# 同步模式下应直接 ready
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "mock-voice-789"
|
||||
# 异步模式下提交后状态为 processing,voice_id 为空
|
||||
assert data["status"] == "processing"
|
||||
assert data["voice_id"] == ""
|
||||
assert data["error_message"] == ""
|
||||
|
||||
def test_create_without_source_audio(self, client):
|
||||
@@ -554,16 +559,15 @@ class TestRetryVoiceClone:
|
||||
"""重试克隆端点测试。"""
|
||||
|
||||
def test_retry_failed_clone(self, client, clone_repo, cosyvoice_service):
|
||||
"""重试失败的克隆应成功。"""
|
||||
cosyvoice_service.async_mode = False
|
||||
"""重试失败的克隆,重新提交后期望 processing。"""
|
||||
p = _make_clone_profile("重试测试", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 同步模式下重试后应变为 ready
|
||||
assert data["status"] == "ready"
|
||||
# 异步模式下重试后状态为 processing,等待 CosyVoice 完成
|
||||
assert data["status"] == "processing"
|
||||
assert data["retry_count"] >= 1
|
||||
|
||||
def test_retry_nonexistent_returns_404(self, client):
|
||||
@@ -590,7 +594,6 @@ class TestRetryVoiceClone:
|
||||
|
||||
def test_retry_increments_retry_count(self, client, clone_repo, cosyvoice_service):
|
||||
"""重试后重试次数增加。"""
|
||||
cosyvoice_service.async_mode = False
|
||||
p = _make_clone_profile("重试计数", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
@@ -680,7 +683,7 @@ class TestVoiceCloneLifecycle:
|
||||
# 4. 状态
|
||||
status_resp = client.get(f"/voice-clones/{clone_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()["status"] == "ready"
|
||||
assert status_resp.json()["status"] == "processing"
|
||||
|
||||
# 5. 删除
|
||||
del_resp = client.delete(f"/voice-clones/{clone_id}")
|
||||
@@ -691,7 +694,7 @@ class TestVoiceCloneLifecycle:
|
||||
assert list_resp2.json()["total"] == 0
|
||||
|
||||
def test_failed_retry_flow(self, client, clone_repo, cosyvoice_service):
|
||||
"""失败 → 重试 → 成功 流程。"""
|
||||
"""失败 → 重试 → processing(等待异步完成) 流程。"""
|
||||
# 创建一个失败的克隆
|
||||
p = _make_clone_profile("失败重试", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
@@ -701,15 +704,13 @@ class TestVoiceCloneLifecycle:
|
||||
assert status_resp.json()["status"] == "failed"
|
||||
|
||||
# 重试
|
||||
cosyvoice_service.async_mode = False
|
||||
retry_resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
assert retry_resp.json()["status"] == "ready"
|
||||
assert retry_resp.json()["status"] == "processing"
|
||||
|
||||
# 再次确认状态
|
||||
status_resp2 = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert status_resp2.json()["status"] == "ready"
|
||||
assert status_resp2.json()["voice_id"] != ""
|
||||
assert status_resp2.json()["status"] == "processing"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# 灰度对比测试工具
|
||||
|
||||
用于统一渲染引擎灰度发布期间的新旧引擎对比验证。
|
||||
|
||||
## 能力
|
||||
|
||||
- **像素对比**:基于 FFmpeg SSIM + PSNR 双指标,评估视频画质差异
|
||||
- **音频对比**:基于差值音频 RMS,评估音频波形差异
|
||||
- **批量对比**:10个预设场景覆盖 P0/P1/P2 优先级
|
||||
- **HTML 报告**:可视化对比结果,包含画质、音频、性能三维度
|
||||
- **两种切换方式**:支持 engine 参数直传 或 Feature Flag 白名单切换
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/render_compare/
|
||||
├── __init__.py # 包导出
|
||||
├── README.md # 本文档
|
||||
├── video_diff.py # 视频像素对比(SSIM + PSNR)
|
||||
├── audio_diff.py # 音频对比(差值 RMS)
|
||||
├── scenarios.py # 预定义对比场景(10个)
|
||||
└── runner.py # 批量对比执行器 + HTML 报告生成
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- FFmpeg 4.4+(需带 ssim 和 psnr 滤镜)
|
||||
- Python 3.10+
|
||||
- httpx(API 调用)
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
```bash
|
||||
export STAGING_API_URL=https://api.staging.example.com
|
||||
export STAGING_API_KEY=your_api_key
|
||||
export STAGING_INTERNAL_API_KEY=your_internal_key # 可选,Feature Flag 模式需要
|
||||
```
|
||||
|
||||
### 运行对比
|
||||
|
||||
```bash
|
||||
# 运行所有 P0 场景(最核心的5个)
|
||||
python -m tests.render_compare.runner --priority P0 --output ./report/
|
||||
|
||||
# 运行 P0 + P1 场景
|
||||
python -m tests.render_compare.runner --priority P1 --output ./report/
|
||||
|
||||
# 只跑指定场景
|
||||
python -m tests.render_compare.runner --scenarios simple_pass_through,subtitle_rendering
|
||||
|
||||
# 使用 Feature Flag 方式切换引擎(需要 internal key)
|
||||
python -m tests.render_compare.runner --priority P0 --flag-mode
|
||||
|
||||
# 自定义阈值
|
||||
python -m tests.render_compare.runner --priority P0 --ssim-threshold 0.95 --psnr-threshold 30
|
||||
```
|
||||
|
||||
## 对比场景
|
||||
|
||||
| ID | 名称 | 优先级 | 验证点 |
|
||||
|----|------|--------|--------|
|
||||
| simple_pass_through | 简单直通 | P0 | 直通优化路径正确性 |
|
||||
| multi_clip_transition | 多clip转场 | P0 | 转场效果 + concat |
|
||||
| subtitle_rendering | 字幕渲染 | P0 | ASS字幕渲染 |
|
||||
| independent_audio_track | 独立音频轨 | P0 | 音频混音(amix) |
|
||||
| no_audio_video | 无音轨视频 | P0 | 无音轨防御逻辑 |
|
||||
| picture_in_picture | 画中画 | P1 | overlay 图层 |
|
||||
| multi_layer_mix | 多图层混合 | P1 | 多图层复杂场景 |
|
||||
| image_background | 图片背景 | P1 | background 层 + 无音频 |
|
||||
| long_video_stress | 长视频压力 | P2 | 多clip性能 |
|
||||
| vertical_portrait | 竖屏9:16 | P2 | scale 策略(铺满裁剪) |
|
||||
|
||||
## 验收标准(建议)
|
||||
|
||||
### 视频质量
|
||||
- **平均 SSIM >= 0.90**:通过(有微小差异但视觉可接受)
|
||||
- **平均 SSIM >= 0.95**:优秀(视觉几乎无差异)
|
||||
- **平均 PSNR >= 25 dB**:通过
|
||||
- **分辨率一致 + 时长差 < 0.1s**:通过
|
||||
|
||||
### 音频质量
|
||||
- **相似度 >= 0.85**:通过
|
||||
- **采样率/声道数一致**:通过
|
||||
|
||||
### 性能
|
||||
- **平均性能差异在 ±10% 以内**:可接受
|
||||
- **直通场景新引擎更快**(预期 +30%)
|
||||
|
||||
## API 约定
|
||||
|
||||
Runner 默认假设渲染 API 支持以下接口:
|
||||
|
||||
### 提交任务
|
||||
```
|
||||
POST /api/v1/render/compose
|
||||
Authorization: Bearer {api_key}
|
||||
Body: { ...plan_payload, "engine": "legacy" | "unified" }
|
||||
Response: { "task_id": "xxx" }
|
||||
```
|
||||
|
||||
### 查询状态
|
||||
```
|
||||
GET /api/v1/tasks/{task_id}
|
||||
Response: { "status": "completed", "output_url": "...", "duration_sec": 5.2 }
|
||||
```
|
||||
|
||||
### Feature Flag(flag-mode)
|
||||
```
|
||||
PUT /api/v1/internal/feature-flags/render_engine
|
||||
X-API-Key: {internal_key}
|
||||
Body: { "enabled": true, "percentage": 100 }
|
||||
```
|
||||
|
||||
如果你的 API 接口不同,请修改 `StagingAPI` 类中的对应方法。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 对比失败定位指南
|
||||
|
||||
1. **像素差异大(SSIM < 0.90)**
|
||||
- 检查分辨率是否一致
|
||||
- 检查帧率是否一致
|
||||
- 用 `save_diff_frame` 生成差异帧可视化
|
||||
- 检查转场效果(slideup/slidedown 是新引擎独有)
|
||||
|
||||
2. **音频不一致**
|
||||
- 检查音频编码参数(码率、采样率)
|
||||
- 检查主音频源优先级(main > broll)
|
||||
- 用 ffprobe 对比两视频音频流参数
|
||||
|
||||
3. **渲染失败**
|
||||
- 检查日志:`[unified-render] render failed`
|
||||
- 检查素材是否完整下载
|
||||
- 检查 FFmpeg 命令是否正确
|
||||
@@ -0,0 +1,26 @@
|
||||
"""灰度对比测试工具包.
|
||||
|
||||
用于新旧渲染引擎的批量对比测试,包含:
|
||||
- video_diff: 视频像素对比(SSIM + PSNR)
|
||||
- audio_diff: 音频对比(差值 RMS)
|
||||
- scenarios: 预定义对比场景
|
||||
- runner: 批量对比执行器 + HTML 报告
|
||||
"""
|
||||
|
||||
from .audio_diff import AudioDiffResult, compute_audio_diff, extract_audio, probe_duration, probe_has_audio
|
||||
from .scenarios import SCENARIOS, CompareScenario, get_scenarios_by_priority
|
||||
from .video_diff import VideoDiffResult, compute_video_diff, save_diff_frame
|
||||
|
||||
__all__ = [
|
||||
"VideoDiffResult",
|
||||
"compute_video_diff",
|
||||
"save_diff_frame",
|
||||
"AudioDiffResult",
|
||||
"compute_audio_diff",
|
||||
"extract_audio",
|
||||
"probe_has_audio",
|
||||
"probe_duration",
|
||||
"SCENARIOS",
|
||||
"CompareScenario",
|
||||
"get_scenarios_by_priority",
|
||||
]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""音频对比工具 — 基于 FFmpeg 的音频质量对比.
|
||||
|
||||
使用以下指标评估两段音频的相似度:
|
||||
1. 波形差异(RMS 差值)
|
||||
2. 频谱相似度(FFT 分帧比较)
|
||||
3. 时长差异
|
||||
|
||||
对比方式:
|
||||
- 直接对两个音频做 `ametadata=select='gt(scene\\,0.3)'` 过于复杂
|
||||
- 简化方案:用 `amerge` + `astats` 计算差值音频的 RMS
|
||||
|
||||
更精确的方案(已实现):
|
||||
- 将两轨音频做差(amix=0:weights='1 -1' → 实际上用 pan 更简单)
|
||||
- 对差值音频做 astats,获取差值的 RMS、峰值等指标
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioDiffResult:
|
||||
"""音频对比结果."""
|
||||
|
||||
audio_a: str
|
||||
audio_b: str
|
||||
duration_a: float
|
||||
duration_b: float
|
||||
duration_diff: float
|
||||
sample_rate_match: bool
|
||||
channels_match: bool
|
||||
diff_rms_db: float # 差值音频的 RMS(dB,越低越相似)
|
||||
diff_peak_db: float # 差值音频的峰值(dB,越低越相似)
|
||||
similarity_score: float # 综合相似度评分 [0, 1],1 = 完全一致
|
||||
passed: bool
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def probe_duration(file_path: str) -> float:
|
||||
"""探测文件时长(秒),失败返回 0."""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(file_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return round(float(result.stdout.strip()), 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def probe_has_audio(file_path: str | Path) -> bool:
|
||||
"""探测文件是否包含音频流."""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_type",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(file_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.stdout.strip() == "audio"
|
||||
except Exception:
|
||||
return False # 探测失败保守返回 False,避免误判有音频
|
||||
|
||||
|
||||
def compute_audio_diff(
|
||||
audio_a: str | Path,
|
||||
audio_b: str | Path,
|
||||
*,
|
||||
similarity_threshold: float = 0.90,
|
||||
duration_tolerance: float = 0.1,
|
||||
) -> AudioDiffResult:
|
||||
"""计算两段音频的差异.
|
||||
|
||||
方案:用 pan 滤镜将两轨相减,对差值音频做 astats 分析。
|
||||
|
||||
Args:
|
||||
audio_a: 音频A(基线)
|
||||
audio_b: 音频B(对比)
|
||||
similarity_threshold: 相似度合格阈值
|
||||
duration_tolerance: 时长容忍度(秒)
|
||||
|
||||
Returns:
|
||||
AudioDiffResult 对比结果
|
||||
"""
|
||||
dur_a = probe_duration(str(audio_a))
|
||||
dur_b = probe_duration(str(audio_b))
|
||||
duration_diff = abs(dur_a - dur_b)
|
||||
|
||||
# 获取音频元信息
|
||||
info_a = _probe_audio_info(str(audio_a))
|
||||
info_b = _probe_audio_info(str(audio_b))
|
||||
|
||||
sample_rate_match = info_a["sample_rate"] == info_b["sample_rate"]
|
||||
channels_match = info_a["channels"] == info_b["channels"]
|
||||
|
||||
# 相减后分析差值
|
||||
# 取较短时长做对比
|
||||
min_dur = min(dur_a, dur_b)
|
||||
if min_dur <= 0:
|
||||
return AudioDiffResult(
|
||||
audio_a=str(audio_a),
|
||||
audio_b=str(audio_b),
|
||||
duration_a=dur_a,
|
||||
duration_b=dur_b,
|
||||
duration_diff=duration_diff,
|
||||
sample_rate_match=sample_rate_match,
|
||||
channels_match=channels_match,
|
||||
diff_rms_db=-999.0,
|
||||
diff_peak_db=-999.0,
|
||||
similarity_score=0.0,
|
||||
passed=False,
|
||||
)
|
||||
|
||||
# 做差值音频:a - b
|
||||
# 注意:amix 会自动按输入数归一化音量(除以N),
|
||||
# 所以 a + (-1)*b 经过 amix=inputs=2 后整体音量会减半(-6dB)。
|
||||
# 加 volume=2 补偿回来,确保差值 RMS 反映真实差异幅度。
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-i",
|
||||
str(audio_a),
|
||||
"-i",
|
||||
str(audio_b),
|
||||
"-filter_complex",
|
||||
# 第2轨反相 → amix混合 → volume=2补偿amix的自动缩放
|
||||
"[1:a]volume=-1[inv];[0:a][inv]amix=inputs=2:duration=shortest:dropout_transition=0,volume=2[diff]",
|
||||
"-map",
|
||||
"[diff]",
|
||||
"-f",
|
||||
"null",
|
||||
"-af",
|
||||
"astats=metadata=1:reset=0",
|
||||
"-",
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
stderr = result.stderr or ""
|
||||
except subprocess.CalledProcessError as e:
|
||||
# 如果音频格式不兼容,返回失败
|
||||
return AudioDiffResult(
|
||||
audio_a=str(audio_a),
|
||||
audio_b=str(audio_b),
|
||||
duration_a=dur_a,
|
||||
duration_b=dur_b,
|
||||
duration_diff=duration_diff,
|
||||
sample_rate_match=sample_rate_match,
|
||||
channels_match=channels_match,
|
||||
diff_rms_db=999.0,
|
||||
diff_peak_db=999.0,
|
||||
similarity_score=0.0,
|
||||
passed=False,
|
||||
)
|
||||
|
||||
diff_rms_db, diff_peak_db = _parse_astats(stderr)
|
||||
|
||||
# 相似度评分:基于差值 RMS
|
||||
# 差值 RMS -60dB → 相似度 ~1.0(几乎无声差)
|
||||
# 差值 RMS -20dB → 相似度 ~0.5(有明显差异)
|
||||
# 差值 RMS 0dB → 相似度 ~0.0(完全相反)
|
||||
if diff_rms_db <= -60:
|
||||
similarity_score = 1.0
|
||||
elif diff_rms_db >= 0:
|
||||
similarity_score = 0.0
|
||||
else:
|
||||
# 线性映射:-60dB → 1.0, 0dB → 0.0
|
||||
similarity_score = max(0.0, min(1.0, 1.0 + diff_rms_db / 60.0))
|
||||
|
||||
passed = (
|
||||
duration_diff <= duration_tolerance
|
||||
and sample_rate_match
|
||||
and channels_match
|
||||
and similarity_score >= similarity_threshold
|
||||
)
|
||||
|
||||
return AudioDiffResult(
|
||||
audio_a=str(audio_a),
|
||||
audio_b=str(audio_b),
|
||||
duration_a=round(dur_a, 3),
|
||||
duration_b=round(dur_b, 3),
|
||||
duration_diff=round(duration_diff, 3),
|
||||
sample_rate_match=sample_rate_match,
|
||||
channels_match=channels_match,
|
||||
diff_rms_db=round(diff_rms_db, 2),
|
||||
diff_peak_db=round(diff_peak_db, 2),
|
||||
similarity_score=round(similarity_score, 4),
|
||||
passed=passed,
|
||||
)
|
||||
|
||||
|
||||
def _probe_audio_info(file_path: str) -> dict[str, int]:
|
||||
"""探测音频元信息."""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=sample_rate,channels",
|
||||
"-of",
|
||||
"json",
|
||||
file_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
info = json.loads(result.stdout)
|
||||
stream = info.get("streams", [{}])[0]
|
||||
return {
|
||||
"sample_rate": int(stream.get("sample_rate", 44100)),
|
||||
"channels": int(stream.get("channels", 2)),
|
||||
}
|
||||
except Exception:
|
||||
return {"sample_rate": 0, "channels": 0}
|
||||
|
||||
|
||||
def _parse_astats(stderr: str) -> tuple[float, float]:
|
||||
"""从 astats 输出中解析 RMS 和峰值.
|
||||
|
||||
astats 输出格式(在 stderr 中):
|
||||
[Parsed_astats_1 @ 0x...] Channel: 1
|
||||
[Parsed_astats_1 @ 0x...] ...
|
||||
[Parsed_astats_1 @ 0x...] Overall
|
||||
[Parsed_astats_1 @ 0x...] DC offset: 0.000000
|
||||
[Parsed_astats_1 @ 0x...] Min level: -0.123456
|
||||
[Parsed_astats_1 @ 0x...] Max level: 0.789012
|
||||
[Parsed_astats_1 @ 0x...] Peak level dB: -2.01
|
||||
[Parsed_astats_1 @ 0x...] RMS level dB: -10.56
|
||||
...
|
||||
"""
|
||||
lines = stderr.split("\n")
|
||||
rms_db = -999.0
|
||||
peak_db = -999.0
|
||||
|
||||
for line in lines:
|
||||
# 找 Overall 部分的统计(双声道时取整体值)
|
||||
rms_match = re.search(r"RMS level dB:\s*(-?\d+\.?\d*)", line)
|
||||
peak_match = re.search(r"Peak level dB:\s*(-?\d+\.?\d*)", line)
|
||||
if rms_match:
|
||||
rms_db = float(rms_match.group(1))
|
||||
if peak_match:
|
||||
peak_db = float(peak_match.group(1))
|
||||
|
||||
return rms_db, peak_db
|
||||
|
||||
|
||||
def extract_audio(video_path: str | Path, output_path: str | Path) -> Path:
|
||||
"""从视频中提取音频(AAC 格式).
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出音频路径
|
||||
|
||||
Returns:
|
||||
输出音频文件路径
|
||||
"""
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
subprocess.run(command, check=True, capture_output=True, timeout=120) # nosec B603
|
||||
return Path(output_path)
|
||||
@@ -0,0 +1,628 @@
|
||||
"""灰度对比测试 Runner — 新旧引擎批量对比 + 报告生成.
|
||||
|
||||
使用方法:
|
||||
# 配置环境变量
|
||||
export STAGING_API_URL=https://api.staging.example.com
|
||||
export STAGING_API_KEY=your_key
|
||||
|
||||
# 运行全部 P0 场景
|
||||
python -m tests.render_compare.runner --priority P0 --output ./report/
|
||||
|
||||
# 只跑指定场景
|
||||
python -m tests.render_compare.runner --scenario simple_pass_through,subtitle_rendering
|
||||
|
||||
对比流程:
|
||||
1. 对每个场景,分别提交到 legacy 和 unified 引擎(通过 Feature Flag 白名单/百分比控制)
|
||||
- 方式A:通过内部 API 临时切换 flag(需要 admin key)
|
||||
- 方式B:提交任务时指定 engine 参数(如果 API 支持)
|
||||
2. 等待任务完成,下载输出视频
|
||||
3. 像素对比(SSIM + PSNR)+ 音频对比(差值RMS)
|
||||
4. 生成 HTML 对比报告
|
||||
|
||||
注意:默认假设 API 支持 `engine` 参数来指定渲染引擎。
|
||||
如果不支持,需要先通过内部 API 切换 Feature Flag,然后提交任务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
# 确保项目根目录在 path 中
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from .audio_diff import AudioDiffResult, compute_audio_diff
|
||||
from .scenarios import SCENARIOS, CompareScenario, get_scenarios_by_priority
|
||||
from .video_diff import VideoDiffResult, compute_video_diff
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScenarioResult:
|
||||
"""单个场景的对比结果."""
|
||||
|
||||
scenario: CompareScenario
|
||||
legacy_task_id: str = ""
|
||||
unified_task_id: str = ""
|
||||
legacy_video_path: str = ""
|
||||
unified_video_path: str = ""
|
||||
legacy_duration_sec: float = 0.0
|
||||
unified_duration_sec: float = 0.0
|
||||
video_diff: VideoDiffResult | None = None
|
||||
audio_diff: AudioDiffResult | None = None
|
||||
legacy_success: bool = False
|
||||
unified_success: bool = False
|
||||
error: str = ""
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
if not (self.legacy_success and self.unified_success):
|
||||
return False
|
||||
if self.video_diff and not self.video_diff.passed:
|
||||
return False
|
||||
if self.audio_diff and not self.audio_diff.passed:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class StagingAPI:
|
||||
"""Staging 环境 API 客户端."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, internal_api_key: str = ""):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.internal_api_key = internal_api_key
|
||||
self.client = httpx.Client(timeout=30.0)
|
||||
|
||||
def _headers(self, internal: bool = False) -> dict[str, str]:
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
if internal and self.internal_api_key:
|
||||
headers["X-API-Key"] = self.internal_api_key
|
||||
return headers
|
||||
|
||||
def submit_render_task(self, plan_payload: dict[str, Any], engine: str = "") -> str:
|
||||
"""提交渲染任务,返回 task_id.
|
||||
|
||||
Args:
|
||||
plan_payload: EditPlan payload
|
||||
engine: 可选,指定引擎("legacy" / "unified")
|
||||
|
||||
Returns:
|
||||
task_id
|
||||
"""
|
||||
url = f"{self.base_url}/api/v1/render/compose"
|
||||
payload = dict(plan_payload)
|
||||
if engine:
|
||||
payload["engine"] = engine
|
||||
resp = self.client.post(url, json=payload, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("task_id") or data.get("id", "")
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""获取任务状态."""
|
||||
url = f"{self.base_url}/api/v1/tasks/{task_id}"
|
||||
resp = self.client.get(url, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def wait_for_task(self, task_id: str, timeout: float = 300.0, poll_interval: float = 3.0) -> dict[str, Any]:
|
||||
"""等待任务完成.
|
||||
|
||||
Returns:
|
||||
最终任务状态
|
||||
|
||||
Raises:
|
||||
TimeoutError: 超时
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
status = self.get_task_status(task_id)
|
||||
state = status.get("status", "")
|
||||
if state in ("completed", "success", "done", "failed", "error"):
|
||||
return status
|
||||
time.sleep(poll_interval)
|
||||
raise TimeoutError(f"Task {task_id} timed out after {timeout}s")
|
||||
|
||||
def set_feature_flag(self, flag_name: str, enabled: bool, percentage: int = 0, whitelist: list[str] | None = None):
|
||||
"""通过内部 API 设置 Feature Flag.
|
||||
|
||||
用于不支持 engine 参数的场景,切换全局灰度比例。
|
||||
"""
|
||||
if not self.internal_api_key:
|
||||
raise ValueError("internal_api_key is required for feature flag operations")
|
||||
url = f"{self.base_url}/api/v1/internal/feature-flags/{flag_name}"
|
||||
body: dict[str, Any] = {"enabled": enabled, "percentage": percentage}
|
||||
if whitelist is not None:
|
||||
body["whitelist"] = whitelist
|
||||
resp = self.client.put(url, json=body, headers=self._headers(internal=True))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def get_feature_flag(self, flag_name: str) -> dict[str, Any]:
|
||||
"""获取 Feature Flag 配置."""
|
||||
if not self.internal_api_key:
|
||||
raise ValueError("internal_api_key is required")
|
||||
url = f"{self.base_url}/api/v1/internal/feature-flags/{flag_name}"
|
||||
resp = self.client.get(url, headers=self._headers(internal=True))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def download_video(self, video_url: str, output_path: str | Path) -> Path:
|
||||
"""下载视频文件."""
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.client.stream("GET", video_url, timeout=60.0) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes():
|
||||
f.write(chunk)
|
||||
return output_path
|
||||
|
||||
|
||||
class CompareRunner:
|
||||
"""新旧引擎对比 Runner."""
|
||||
|
||||
# 全局默认阈值(唯一真实来源,所有入口统一引用)
|
||||
DEFAULT_SSIM_THRESHOLD: float = 0.95
|
||||
DEFAULT_PSNR_THRESHOLD: float = 28.0
|
||||
DEFAULT_AUDIO_SIMILARITY_THRESHOLD: float = 0.90
|
||||
DEFAULT_DURATION_TOLERANCE: float = 0.1
|
||||
DEFAULT_TASK_TIMEOUT: float = 300.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api: StagingAPI,
|
||||
output_dir: Path,
|
||||
*,
|
||||
ssim_threshold: float | None = None,
|
||||
psnr_threshold: float | None = None,
|
||||
audio_similarity_threshold: float | None = None,
|
||||
task_timeout: float | None = None,
|
||||
flag_mode: bool = False, # 是否使用 Feature Flag 方式切换引擎
|
||||
duration_tolerance: float | None = None,
|
||||
):
|
||||
self.api = api
|
||||
self.output_dir = output_dir
|
||||
self.ssim_threshold = ssim_threshold if ssim_threshold is not None else self.DEFAULT_SSIM_THRESHOLD
|
||||
self.psnr_threshold = psnr_threshold if psnr_threshold is not None else self.DEFAULT_PSNR_THRESHOLD
|
||||
self.audio_similarity_threshold = (
|
||||
audio_similarity_threshold
|
||||
if audio_similarity_threshold is not None
|
||||
else self.DEFAULT_AUDIO_SIMILARITY_THRESHOLD
|
||||
)
|
||||
self.duration_tolerance = (
|
||||
duration_tolerance if duration_tolerance is not None else self.DEFAULT_DURATION_TOLERANCE
|
||||
)
|
||||
self.task_timeout = task_timeout if task_timeout is not None else self.DEFAULT_TASK_TIMEOUT
|
||||
self.flag_mode = flag_mode
|
||||
self.results: list[ScenarioResult] = []
|
||||
# flag_mode 下保存原始配置,测试结束后恢复(防污染线上)
|
||||
self._original_flag_config: dict[str, Any] | None = None
|
||||
|
||||
def run_scenario(self, scenario: CompareScenario) -> ScenarioResult:
|
||||
"""运行单个场景对比."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[{scenario.priority}] {scenario.id}: {scenario.name}")
|
||||
print(f" {scenario.description}")
|
||||
|
||||
result = ScenarioResult(scenario=scenario)
|
||||
scenario_dir = self.output_dir / scenario.id
|
||||
scenario_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 1. 提交两个引擎的任务
|
||||
legacy_task_id = self._submit_with_engine(scenario, "legacy")
|
||||
unified_task_id = self._submit_with_engine(scenario, "unified")
|
||||
result.legacy_task_id = legacy_task_id
|
||||
result.unified_task_id = unified_task_id
|
||||
print(f" legacy task: {legacy_task_id}")
|
||||
print(f" unified task: {unified_task_id}")
|
||||
|
||||
# 2. 等待完成
|
||||
print(" waiting for legacy...", end="", flush=True)
|
||||
legacy_status = self.api.wait_for_task(legacy_task_id, timeout=self.task_timeout)
|
||||
result.legacy_success = legacy_status.get("status") in ("completed", "success", "done")
|
||||
legacy_video_url = legacy_status.get("output_url", "") or legacy_status.get("video_url", "")
|
||||
print(f" {'✅' if result.legacy_success else '❌'} ({legacy_status.get('duration_sec', '?')}s)")
|
||||
|
||||
print(" waiting for unified...", end="", flush=True)
|
||||
unified_status = self.api.wait_for_task(unified_task_id, timeout=self.task_timeout)
|
||||
result.unified_success = unified_status.get("status") in ("completed", "success", "done")
|
||||
unified_video_url = unified_status.get("output_url", "") or unified_status.get("video_url", "")
|
||||
print(f" {'✅' if result.unified_success else '❌'} ({unified_status.get('duration_sec', '?')}s)")
|
||||
|
||||
result.legacy_duration_sec = float(legacy_status.get("duration_sec", 0))
|
||||
result.unified_duration_sec = float(unified_status.get("duration_sec", 0))
|
||||
|
||||
if not (result.legacy_success and result.unified_success):
|
||||
result.error = f"Legacy success={result.legacy_success}, Unified success={result.unified_success}"
|
||||
print(" ⚠️ 任务未全部成功,跳过对比")
|
||||
return result
|
||||
|
||||
# 3. 下载视频
|
||||
print(" downloading...", end="", flush=True)
|
||||
legacy_path = self.api.download_video(legacy_video_url, scenario_dir / "legacy.mp4")
|
||||
unified_path = self.api.download_video(unified_video_url, scenario_dir / "unified.mp4")
|
||||
result.legacy_video_path = str(legacy_path)
|
||||
result.unified_video_path = str(unified_path)
|
||||
print(" ✅")
|
||||
|
||||
# 4. 像素对比
|
||||
print(" computing video diff...", end="", flush=True)
|
||||
result.video_diff = compute_video_diff(
|
||||
legacy_path,
|
||||
unified_path,
|
||||
ssim_threshold=self.ssim_threshold,
|
||||
psnr_threshold=self.psnr_threshold,
|
||||
duration_tolerance=self.duration_tolerance,
|
||||
)
|
||||
print(
|
||||
f" SSIM={result.video_diff.avg_ssim:.4f} PSNR={result.video_diff.avg_psnr:.2f}dB {'✅' if result.video_diff.passed else '❌'}"
|
||||
)
|
||||
|
||||
# 5. 音频对比(仅当都有音频时)
|
||||
from .audio_diff import probe_has_audio
|
||||
|
||||
legacy_has_audio = probe_has_audio(legacy_path)
|
||||
unified_has_audio = probe_has_audio(unified_path)
|
||||
|
||||
if legacy_has_audio and unified_has_audio:
|
||||
print(" computing audio diff...", end="", flush=True)
|
||||
result.audio_diff = compute_audio_diff(
|
||||
legacy_path,
|
||||
unified_path,
|
||||
similarity_threshold=self.audio_similarity_threshold,
|
||||
)
|
||||
print(
|
||||
f" similarity={result.audio_diff.similarity_score:.4f} {'✅' if result.audio_diff.passed else '❌'}"
|
||||
)
|
||||
elif legacy_has_audio != unified_has_audio:
|
||||
result.error = f"音频不一致: legacy_has_audio={legacy_has_audio}, unified_has_audio={unified_has_audio}"
|
||||
print(f" ⚠️ 音频不一致: legacy={legacy_has_audio}, unified={unified_has_audio}")
|
||||
else:
|
||||
print(" audio: both silent (skip)")
|
||||
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
print(f" ❌ 错误: {e}")
|
||||
|
||||
self.results.append(result)
|
||||
return result
|
||||
|
||||
def _submit_with_engine(self, scenario: CompareScenario, engine: str) -> str:
|
||||
"""提交指定引擎的任务.
|
||||
|
||||
如果 flag_mode=True,通过 Feature Flag 切换,否则通过 engine 参数。
|
||||
"""
|
||||
if self.flag_mode:
|
||||
# 先设置 flag(用白名单方式,确保只有当前测试用户命中)
|
||||
percentage = 0 if engine == "legacy" else 100
|
||||
self.api.set_feature_flag("render_engine", enabled=True, percentage=percentage)
|
||||
time.sleep(1) # 给 worker 一点时间刷新配置
|
||||
return self.api.submit_render_task(scenario.plan_payload)
|
||||
else:
|
||||
return self.api.submit_render_task(scenario.plan_payload, engine=engine)
|
||||
|
||||
def run_all(self, scenarios: list[CompareScenario]) -> list[ScenarioResult]:
|
||||
"""运行所有场景.
|
||||
|
||||
flag_mode=True 时,测试开始前保存原始 Feature Flag 配置,
|
||||
结束后(无论成功失败)自动恢复,避免污染线上环境。
|
||||
"""
|
||||
print(f"\n灰度对比测试开始 - {len(scenarios)} 个场景")
|
||||
print(f"输出目录: {self.output_dir}")
|
||||
print(f"视频阈值: SSIM>={self.ssim_threshold}, PSNR>={self.psnr_threshold}dB")
|
||||
print(f"音频阈值: similarity>={self.audio_similarity_threshold}")
|
||||
|
||||
# flag_mode:保存原始配置,测试结束后恢复(防污染)
|
||||
if self.flag_mode:
|
||||
try:
|
||||
self._original_flag_config = self.api.get_feature_flag("render_engine")
|
||||
print(f" [flag_mode] 已保存原始配置: {self._original_flag_config}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ [flag_mode] 保存原始配置失败: {e}")
|
||||
print(" 为避免污染线上,将中止测试。请检查 internal_api_key 配置。")
|
||||
return self.results
|
||||
|
||||
try:
|
||||
for i, scenario in enumerate(scenarios):
|
||||
print(f"\n进度: {i+1}/{len(scenarios)}")
|
||||
self.run_scenario(scenario)
|
||||
finally:
|
||||
# 始终恢复原始 flag 配置
|
||||
if self.flag_mode and self._original_flag_config:
|
||||
try:
|
||||
orig = self._original_flag_config
|
||||
self.api.set_feature_flag(
|
||||
"render_engine",
|
||||
enabled=orig.get("enabled", False),
|
||||
percentage=orig.get("percentage", 0),
|
||||
whitelist=orig.get("whitelist"),
|
||||
)
|
||||
print("\n[flag_mode] ✅ 已恢复原始 Feature Flag 配置")
|
||||
except Exception as e:
|
||||
print(f"\n[flag_mode] ❌ 恢复 Feature Flag 失败: {e}")
|
||||
print(" 请手动检查并恢复 render_engine flag 配置!")
|
||||
|
||||
return self.results
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
"""生成汇总统计."""
|
||||
total = len(self.results)
|
||||
passed = sum(1 for r in self.results if r.passed)
|
||||
failed = total - passed
|
||||
|
||||
# 性能对比
|
||||
perf_diffs = []
|
||||
for r in self.results:
|
||||
if r.legacy_success and r.unified_success and r.legacy_duration_sec > 0:
|
||||
diff_pct = (r.unified_duration_sec - r.legacy_duration_sec) / r.legacy_duration_sec * 100
|
||||
perf_diffs.append(diff_pct)
|
||||
avg_perf_diff = sum(perf_diffs) / len(perf_diffs) if perf_diffs else 0.0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"pass_rate": f"{passed/total*100:.1f}%" if total > 0 else "0%",
|
||||
"avg_perf_diff_pct": round(avg_perf_diff, 2),
|
||||
"scenarios": [self._result_to_dict(r) for r in self.results],
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"ssim_threshold": self.ssim_threshold,
|
||||
"psnr_threshold": self.psnr_threshold,
|
||||
"audio_threshold": self.audio_similarity_threshold,
|
||||
}
|
||||
|
||||
def _result_to_dict(self, r: ScenarioResult) -> dict[str, Any]:
|
||||
return {
|
||||
"id": r.scenario.id,
|
||||
"name": r.scenario.name,
|
||||
"priority": r.scenario.priority,
|
||||
"passed": r.passed,
|
||||
"legacy_success": r.legacy_success,
|
||||
"unified_success": r.unified_success,
|
||||
"legacy_duration_sec": r.legacy_duration_sec,
|
||||
"unified_duration_sec": r.unified_duration_sec,
|
||||
"video_diff": r.video_diff.to_dict() if r.video_diff else None,
|
||||
"audio_diff": r.audio_diff.to_dict() if r.audio_diff else None,
|
||||
"error": r.error,
|
||||
}
|
||||
|
||||
|
||||
def generate_html_report(summary: dict[str, Any], output_path: Path):
|
||||
"""生成 HTML 对比报告."""
|
||||
scenarios = summary["scenarios"]
|
||||
|
||||
# 按通过/失败分组
|
||||
passed_list = [s for s in scenarios if s["passed"]]
|
||||
failed_list = [s for s in scenarios if not s["passed"]]
|
||||
|
||||
# 构建场景卡片
|
||||
scenario_cards = ""
|
||||
for s in scenarios:
|
||||
status_class = "pass" if s["passed"] else "fail"
|
||||
status_text = "✅ 通过" if s["passed"] else "❌ 失败"
|
||||
|
||||
vdiff = s.get("video_diff") or {}
|
||||
adiff = s.get("audio_diff") or {}
|
||||
|
||||
video_info = ""
|
||||
if vdiff:
|
||||
video_info = f"""
|
||||
<div class="metric-row">
|
||||
<span>SSIM:</span>
|
||||
<span class="{'good' if vdiff.get('avg_ssim', 0) >= 0.95 else 'warn'}">{vdiff.get('avg_ssim', 0):.4f}</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span>PSNR:</span>
|
||||
<span>{vdiff.get('avg_psnr', 0):.2f} dB</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span>时长差:</span>
|
||||
<span>{vdiff.get('duration_diff', 0):.3f}s</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
audio_info = ""
|
||||
if adiff:
|
||||
audio_info = f"""
|
||||
<div class="metric-row">
|
||||
<span>音频相似度:</span>
|
||||
<span class="{'good' if adiff.get('similarity_score', 0) >= 0.9 else 'warn'}">{adiff.get('similarity_score', 0):.4f}</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span>差值 RMS:</span>
|
||||
<span>{adiff.get('diff_rms_db', 0):.2f} dB</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
perf_info = ""
|
||||
if s["legacy_duration_sec"] and s["unified_duration_sec"]:
|
||||
diff = s["unified_duration_sec"] - s["legacy_duration_sec"]
|
||||
pct = diff / s["legacy_duration_sec"] * 100 if s["legacy_duration_sec"] else 0
|
||||
trend = "🔴" if pct > 10 else ("🟡" if pct > 0 else "🟢")
|
||||
perf_info = f"""
|
||||
<div class="perf-row">
|
||||
<span>Legacy: {s['legacy_duration_sec']:.2f}s</span>
|
||||
<span>Unified: {s['unified_duration_sec']:.2f}s</span>
|
||||
<span>{trend} {pct:+.1f}%</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
error_info = f'<div class="error-box">{s["error"]}</div>' if s["error"] else ""
|
||||
|
||||
scenario_cards += f"""
|
||||
<div class="card {status_class}">
|
||||
<div class="card-header">
|
||||
<span class="badge">{s['priority']}</span>
|
||||
<span class="scenario-name">{s['name']}</span>
|
||||
<span class="status {status_class}">{status_text}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<h4>视频质量</h4>
|
||||
{video_info or '<p class="muted">无数据</p>'}
|
||||
</div>
|
||||
<div>
|
||||
<h4>音频质量</h4>
|
||||
{audio_info or '<p class="muted">无音频或跳过</p>'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>性能对比</h4>
|
||||
{perf_info or '<p class="muted">无数据</p>'}
|
||||
</div>
|
||||
{error_info}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>统一渲染引擎灰度对比报告</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 20px; }}
|
||||
.container {{ max-width: 1200px; margin: 0 auto; }}
|
||||
h1 {{ margin-bottom: 20px; font-size: 24px; }}
|
||||
.summary {{ background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; display: flex; gap: 32px; flex-wrap: wrap; }}
|
||||
.summary-item {{ text-align: center; }}
|
||||
.summary-item .value {{ font-size: 32px; font-weight: bold; margin-bottom: 4px; }}
|
||||
.summary-item .label {{ color: #666; font-size: 14px; }}
|
||||
.pass .value {{ color: #10b981; }}
|
||||
.fail .value {{ color: #ef4444; }}
|
||||
.card {{ background: white; border-radius: 12px; margin-bottom: 16px; overflow: hidden; border-left: 4px solid #10b981; }}
|
||||
.card.fail {{ border-left-color: #ef4444; }}
|
||||
.card-header {{ padding: 16px 20px; background: #fafafa; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #eee; }}
|
||||
.badge {{ background: #e5e7eb; color: #374151; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }}
|
||||
.scenario-name {{ flex: 1; font-weight: 600; }}
|
||||
.status {{ font-weight: 600; }}
|
||||
.status.pass {{ color: #10b981; }}
|
||||
.status.fail {{ color: #ef4444; }}
|
||||
.card-body {{ padding: 20px; }}
|
||||
.grid-2 {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 16px; }}
|
||||
h4 {{ margin-bottom: 12px; color: #374151; font-size: 14px; }}
|
||||
.metric-row {{ display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }}
|
||||
.metric-row .good {{ color: #10b981; font-weight: 600; }}
|
||||
.metric-row .warn {{ color: #f59e0b; font-weight: 600; }}
|
||||
.perf-row {{ display: flex; gap: 24px; padding: 8px 0; font-size: 14px; background: #f9fafb; padding: 12px; border-radius: 8px; }}
|
||||
.error-box {{ background: #fef2f2; color: #dc2626; padding: 12px; border-radius: 8px; margin-top: 12px; font-size: 13px; }}
|
||||
.muted {{ color: #9ca3af; font-size: 14px; }}
|
||||
.timestamp {{ text-align: center; color: #9ca3af; font-size: 12px; margin-top: 24px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎬 统一渲染引擎灰度对比报告</h1>
|
||||
<div class="summary">
|
||||
<div class="summary-item">
|
||||
<div class="value">{summary['total']}</div>
|
||||
<div class="label">总场景数</div>
|
||||
</div>
|
||||
<div class="summary-item pass">
|
||||
<div class="value">{summary['passed']}</div>
|
||||
<div class="label">通过</div>
|
||||
</div>
|
||||
<div class="summary-item fail">
|
||||
<div class="value">{summary['failed']}</div>
|
||||
<div class="label">失败</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="value">{summary['pass_rate']}</div>
|
||||
<div class="label">通过率</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="value {'good' if summary['avg_perf_diff_pct'] <= 0 else 'warn'}" style="font-size: 24px; color: {'#10b981' if summary['avg_perf_diff_pct'] <= 0 else '#f59e0b'}">{summary['avg_perf_diff_pct']:+.1f}%</div>
|
||||
<div class="label">平均性能差异</div>
|
||||
</div>
|
||||
</div>
|
||||
{scenario_cards}
|
||||
<div class="timestamp">生成时间: {summary['timestamp']}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(html, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="统一渲染引擎灰度对比测试")
|
||||
parser.add_argument("--priority", default="P0", choices=["P0", "P1", "P2"], help="最低优先级")
|
||||
parser.add_argument("--scenarios", default="", help="指定场景ID,逗号分隔")
|
||||
parser.add_argument("--output", default="./gray_compare_report", help="输出目录")
|
||||
parser.add_argument("--ssim-threshold", type=float, default=None, help="SSIM阈值(默认0.95)")
|
||||
parser.add_argument("--psnr-threshold", type=float, default=None, help="PSNR阈值(dB)(默认28.0)")
|
||||
parser.add_argument("--audio-threshold", type=float, default=None, help="音频相似度阈值(默认0.90)")
|
||||
parser.add_argument("--flag-mode", action="store_true", help="使用Feature Flag方式切换引擎")
|
||||
parser.add_argument("--task-timeout", type=float, default=300.0, help="单任务超时时间(秒)")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = os.environ.get("STAGING_API_URL", "")
|
||||
api_key = os.environ.get("STAGING_API_KEY", "")
|
||||
internal_key = os.environ.get("STAGING_INTERNAL_API_KEY", "")
|
||||
|
||||
if not base_url or not api_key:
|
||||
print("❌ 请设置环境变量 STAGING_API_URL 和 STAGING_API_KEY")
|
||||
sys.exit(1)
|
||||
|
||||
# 选择场景
|
||||
if args.scenarios:
|
||||
scenario_ids = [s.strip() for s in args.scenarios.split(",")]
|
||||
selected = [s for s in SCENARIOS if s.id in scenario_ids]
|
||||
if not selected:
|
||||
print(f"❌ 未找到匹配的场景: {scenario_ids}")
|
||||
print(f"可用场景: {[s.id for s in SCENARIOS]}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
selected = get_scenarios_by_priority(args.priority)
|
||||
|
||||
output_dir = Path(args.output).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
api = StagingAPI(base_url, api_key, internal_key)
|
||||
runner = CompareRunner(
|
||||
api,
|
||||
output_dir,
|
||||
ssim_threshold=args.ssim_threshold,
|
||||
psnr_threshold=args.psnr_threshold,
|
||||
audio_similarity_threshold=args.audio_threshold,
|
||||
flag_mode=args.flag_mode,
|
||||
task_timeout=args.task_timeout,
|
||||
)
|
||||
|
||||
runner.run_all(selected)
|
||||
|
||||
# 生成报告
|
||||
summary = runner.summary()
|
||||
|
||||
# JSON 报告
|
||||
json_path = output_dir / "report.json"
|
||||
json_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# HTML 报告
|
||||
html_path = output_dir / "report.html"
|
||||
generate_html_report(summary, html_path)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"对比完成: {summary['passed']}/{summary['total']} 通过 ({summary['pass_rate']})")
|
||||
print(f"报告: {html_path}")
|
||||
print(f"JSON: {json_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""灰度对比测试场景定义 — 覆盖典型渲染场景.
|
||||
|
||||
每个场景对应一个 EditPlan,用于新旧引擎对比。
|
||||
覆盖场景:
|
||||
1. 简单直通(单clip无特效)
|
||||
2. 多clip转场(fade + slide)
|
||||
3. 画中画(main + overlay)
|
||||
4. 字幕渲染(ASS字幕)
|
||||
5. 独立音频轨(主视频 + BGM)
|
||||
6. 多图层混合(main + broll + overlay + audio)
|
||||
7. 背景图片 + 主视频(图片背景无音频)
|
||||
8. 无音频视频(纯画面,验证无音轨防御)
|
||||
9. 长视频(10+ clip,压力测试)
|
||||
10. 分辨率非标(竖屏9:16,验证scale策略)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompareScenario:
|
||||
"""对比测试场景."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
priority: str # P0 / P1 / P2
|
||||
plan_payload: dict[str, Any] # EditPlan JSON payload(提交给 API 的数据)
|
||||
expected: dict[str, Any] = field(default_factory=dict) # 预期结果
|
||||
|
||||
|
||||
SCENARIOS: list[CompareScenario] = [
|
||||
CompareScenario(
|
||||
id="simple_pass_through",
|
||||
name="简单直通",
|
||||
description="单主clip,无转场无特效,验证直通优化路径",
|
||||
priority="P0",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 5.0,
|
||||
"order": 0,
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="multi_clip_transition",
|
||||
name="多clip转场",
|
||||
description="3个clip,fade + slideleft 转场",
|
||||
priority="P0",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 3.0,
|
||||
"order": 0,
|
||||
"transition_effect": "cut",
|
||||
},
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 3.0,
|
||||
"order": 1,
|
||||
"transition_effect": "fade",
|
||||
},
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 3.0,
|
||||
"order": 2,
|
||||
"transition_effect": "slideleft",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="picture_in_picture",
|
||||
name="画中画",
|
||||
description="主视频 + 角落小窗(corner_voice)",
|
||||
priority="P1",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{"clip_type": "main", "asset_id": "sample_5s.mp4", "duration": 5.0, "order": 0},
|
||||
{"clip_type": "corner_voice", "asset_id": "sample_5s.mp4", "duration": 5.0, "order": 0},
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="subtitle_rendering",
|
||||
name="字幕渲染",
|
||||
description="主视频 + ASS字幕",
|
||||
priority="P0",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 5.0,
|
||||
"order": 0,
|
||||
"config": {"subtitles": [{"text": "测试字幕 Test Subtitle", "start_time": 0, "end_time": 5.0}]},
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="independent_audio_track",
|
||||
name="独立音频轨",
|
||||
description="主视频(带音频)+ 独立BGM轨,验证音频混音",
|
||||
priority="P0",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{"clip_type": "main", "asset_id": "sample_5s.mp4", "duration": 5.0, "order": 0},
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_bgm.mp3",
|
||||
"duration": 5.0,
|
||||
"order": 0,
|
||||
"config": {"role": "audio", "volume": 0.5},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="multi_layer_mix",
|
||||
name="多图层混合",
|
||||
description="main + broll + overlay + audio 四图层",
|
||||
priority="P1",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 4.0,
|
||||
"order": 0,
|
||||
"transition_effect": "fade",
|
||||
},
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 4.0,
|
||||
"order": 1,
|
||||
"transition_effect": "slideup",
|
||||
},
|
||||
{"clip_type": "broll", "asset_id": "sample_broll.mp4", "duration": 8.0, "order": 0},
|
||||
{"clip_type": "overlay", "asset_id": "sample_overlay.png", "duration": 8.0, "order": 0},
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_bgm.mp3",
|
||||
"duration": 8.0,
|
||||
"order": 0,
|
||||
"config": {"role": "audio", "volume": 0.3},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="image_background",
|
||||
name="图片背景",
|
||||
description="background图片层 + 主视频,验证背景层无音频",
|
||||
priority="P1",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{"clip_type": "background", "asset_id": "sample_bg.jpg", "duration": 5.0, "order": 0},
|
||||
{"clip_type": "main", "asset_id": "sample_5s.mp4", "duration": 5.0, "order": 0},
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="no_audio_video",
|
||||
name="无音轨视频",
|
||||
description="源视频无音频流,验证无音轨防御逻辑",
|
||||
priority="P0",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{"clip_type": "main", "asset_id": "sample_silent_5s.mp4", "duration": 5.0, "order": 0},
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="long_video_stress",
|
||||
name="长视频压力",
|
||||
description="10个clip + 多种转场,性能压力测试",
|
||||
priority="P2",
|
||||
plan_payload={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{
|
||||
"clip_type": "main",
|
||||
"asset_id": "sample_5s.mp4",
|
||||
"duration": 3.0,
|
||||
"order": i,
|
||||
"transition_effect": ["cut", "fade", "slideleft", "slidedown", "dissolve"][i % 5],
|
||||
}
|
||||
for i in range(10)
|
||||
],
|
||||
},
|
||||
),
|
||||
CompareScenario(
|
||||
id="vertical_portrait",
|
||||
name="竖屏9:16",
|
||||
description="竖屏分辨率,验证scale策略(铺满裁剪)",
|
||||
priority="P2",
|
||||
plan_payload={
|
||||
"width": 720,
|
||||
"height": 1280,
|
||||
"fps": 25,
|
||||
"clips": [
|
||||
{"clip_type": "main", "asset_id": "sample_5s.mp4", "duration": 5.0, "order": 0},
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_scenarios_by_priority(min_priority: str = "P2") -> list[CompareScenario]:
|
||||
"""按优先级过滤场景.
|
||||
|
||||
P0 包含 P0
|
||||
P1 包含 P0 + P1
|
||||
P2 包含全部
|
||||
"""
|
||||
priority_order = {"P0": 0, "P1": 1, "P2": 2}
|
||||
threshold = priority_order.get(min_priority, 2)
|
||||
return [s for s in SCENARIOS if priority_order.get(s.priority, 2) <= threshold]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""视频对比工具 — 基于 FFmpeg 的像素级质量对比.
|
||||
|
||||
使用 SSIM + PSNR 双指标评估两个视频的相似度:
|
||||
- SSIM (Structural Similarity): 结构相似性,范围 [0, 1],越接近 1 越相似
|
||||
- PSNR (Peak Signal-to-Noise Ratio): 峰值信噪比,单位 dB,越高越好
|
||||
|
||||
灰度验收标准:
|
||||
- 平均 SSIM >= 0.95 → 视觉上几乎无差异(P0 场景必达)
|
||||
- 最低 SSIM >= 0.90 → 最严重帧差异可接受
|
||||
- 平均 PSNR >= 28dB → 质量达标
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoDiffResult:
|
||||
"""视频对比结果."""
|
||||
|
||||
video_a: str
|
||||
video_b: str
|
||||
width: int
|
||||
height: int
|
||||
duration_a: float
|
||||
duration_b: float
|
||||
avg_ssim: float
|
||||
min_ssim: float
|
||||
avg_psnr: float # dB
|
||||
min_psnr: float
|
||||
frame_count: int
|
||||
duration_diff: float # 时长差(秒)
|
||||
resolution_match: bool
|
||||
passed: bool # 是否通过阈值
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def probe_video_info(video_path: str) -> dict[str, Any]:
|
||||
"""获取视频信息(宽、高、时长、fps)."""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
info = json.loads(result.stdout)
|
||||
stream = info.get("streams", [{}])[0]
|
||||
fmt = info.get("format", {})
|
||||
|
||||
width = int(stream.get("width", 1280))
|
||||
height = int(stream.get("height", 720))
|
||||
fps_str = stream.get("r_frame_rate", "25/1")
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) > 0 else 25.0
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 25.0
|
||||
duration = float(fmt.get("duration", 0)) or float(stream.get("duration", 0))
|
||||
|
||||
return {"width": width, "height": height, "duration": duration, "fps": round(fps, 2)}
|
||||
except Exception:
|
||||
return {"width": 1280, "height": 720, "duration": 0.0, "fps": 25.0}
|
||||
|
||||
|
||||
def compute_video_diff(
|
||||
video_a: str | Path,
|
||||
video_b: str | Path,
|
||||
*,
|
||||
ssim_threshold: float = 0.95,
|
||||
psnr_threshold: float = 28.0,
|
||||
duration_tolerance: float = 0.1,
|
||||
) -> VideoDiffResult:
|
||||
"""计算两个视频的像素差异.
|
||||
|
||||
使用 FFmpeg ssim + psnr 滤镜一次性计算两个指标。
|
||||
|
||||
Args:
|
||||
video_a: 视频A路径(基线)
|
||||
video_b: 视频B路径(对比)
|
||||
ssim_threshold: SSIM 合格阈值(默认 0.90)
|
||||
psnr_threshold: PSNR 合格阈值(默认 25dB)
|
||||
duration_tolerance: 时长容忍度(秒,默认 0.1s)
|
||||
|
||||
Returns:
|
||||
VideoDiffResult 对比结果
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: FFmpeg 执行失败
|
||||
"""
|
||||
info_a = probe_video_info(str(video_a))
|
||||
info_b = probe_video_info(str(video_b))
|
||||
|
||||
duration_diff = abs(info_a["duration"] - info_b["duration"])
|
||||
resolution_match = info_a["width"] == info_b["width"] and info_a["height"] == info_b["height"]
|
||||
|
||||
# ssim 和 psnr 的 stats_file 都输出到 stdout
|
||||
# 用行格式区分:SSIM 行含 "All:",PSNR 行含 "psnr_avg:"
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-i",
|
||||
str(video_a),
|
||||
"-i",
|
||||
str(video_b),
|
||||
"-lavfi",
|
||||
"[0:v][1:v]ssim=stats_file=-[out1];[0:v][1:v]psnr=stats_file=-[out2]",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# 逐帧统计在 stdout(stats_file=-),汇总日志在 stderr
|
||||
stats_stdout = result.stdout or ""
|
||||
|
||||
avg_ssim, min_ssim = _parse_ssim_stats(stats_stdout)
|
||||
avg_psnr, min_psnr = _parse_psnr_stats(stats_stdout)
|
||||
frame_count = _count_frames(result.stderr or "")
|
||||
|
||||
passed = (
|
||||
resolution_match
|
||||
and duration_diff <= duration_tolerance
|
||||
and avg_ssim >= ssim_threshold
|
||||
and avg_psnr >= psnr_threshold
|
||||
)
|
||||
|
||||
return VideoDiffResult(
|
||||
video_a=str(video_a),
|
||||
video_b=str(video_b),
|
||||
width=info_a["width"],
|
||||
height=info_a["height"],
|
||||
duration_a=round(info_a["duration"], 3),
|
||||
duration_b=round(info_b["duration"], 3),
|
||||
avg_ssim=round(avg_ssim, 6),
|
||||
min_ssim=round(min_ssim, 6),
|
||||
avg_psnr=round(avg_psnr, 3),
|
||||
min_psnr=round(min_psnr, 3),
|
||||
frame_count=frame_count,
|
||||
duration_diff=round(duration_diff, 3),
|
||||
resolution_match=resolution_match,
|
||||
passed=passed,
|
||||
)
|
||||
|
||||
|
||||
def _parse_ssim_stats(stats_output: str) -> tuple[float, float]:
|
||||
"""从 SSIM stats_file 输出中解析逐帧 SSIM.
|
||||
|
||||
FFmpeg ssim 滤镜 stats_file 输出格式(每行一帧):
|
||||
n:1 Y:0.987654 U:0.991234 V:0.990000 All:0.989000 (19.585642)
|
||||
n:2 Y:0.986543 U:0.990123 V:0.988888 All:0.987654 (19.123456)
|
||||
...
|
||||
|
||||
Returns:
|
||||
(avg_ssim, min_ssim)
|
||||
"""
|
||||
ssim_values: list[float] = []
|
||||
|
||||
for line in stats_output.split("\n"):
|
||||
# 匹配 stats_file 格式:n:数字 ... All:数字
|
||||
if not line.startswith("n:"):
|
||||
continue
|
||||
match = re.search(r"All:(\d+\.\d+)", line)
|
||||
if match:
|
||||
ssim_values.append(float(match.group(1)))
|
||||
|
||||
if not ssim_values:
|
||||
return 0.0, 0.0
|
||||
|
||||
avg_ssim = sum(ssim_values) / len(ssim_values)
|
||||
min_ssim = min(ssim_values)
|
||||
return avg_ssim, min_ssim
|
||||
|
||||
|
||||
def _parse_psnr_stats(stats_output: str) -> tuple[float, float]:
|
||||
"""从 PSNR stats_file 输出中解析逐帧 PSNR.
|
||||
|
||||
FFmpeg psnr 滤镜 stats_file 输出格式(每行一帧):
|
||||
n:1 mse_avg:100.23 mse_y:150.12 mse_u:50.34 mse_v:80.56 psnr_avg:28.12 psnr_y:26.34 psnr_u:31.12 psnr_v:29.08
|
||||
n:2 ...
|
||||
|
||||
Returns:
|
||||
(avg_psnr, min_psnr) — avg_psnr 是逐帧 psnr_avg 的均值,min_psnr 是逐帧最小值
|
||||
"""
|
||||
psnr_values: list[float] = []
|
||||
|
||||
for line in stats_output.split("\n"):
|
||||
if not line.startswith("n:"):
|
||||
continue
|
||||
match = re.search(r"psnr_avg:(\d+\.\d+)", line)
|
||||
if match:
|
||||
psnr_values.append(float(match.group(1)))
|
||||
|
||||
if not psnr_values:
|
||||
return 0.0, 0.0
|
||||
|
||||
avg_psnr = sum(psnr_values) / len(psnr_values)
|
||||
min_psnr = min(psnr_values)
|
||||
return avg_psnr, min_psnr
|
||||
|
||||
|
||||
def _count_frames(stderr: str) -> int:
|
||||
"""从 FFmpeg 输出中统计帧数."""
|
||||
match = re.search(r"frame=\s*(\d+)", stderr)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def save_diff_frame(
|
||||
video_a: str | Path,
|
||||
video_b: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
timestamp: float = 1.0,
|
||||
) -> Path:
|
||||
"""生成差异帧可视化图(红绿色差).
|
||||
|
||||
使用 blend 滤镜生成差异可视化图,差异越大越亮。
|
||||
|
||||
Args:
|
||||
video_a: 视频A
|
||||
video_b: 视频B
|
||||
output_path: 输出图片路径
|
||||
timestamp: 截取的时间点(秒)
|
||||
|
||||
Returns:
|
||||
输出图片路径
|
||||
"""
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
str(video_a),
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
str(video_b),
|
||||
"-lavfi",
|
||||
"[0:v][1:v]blend=all_mode=difference,eq=contrast=5:brightness=0.5[diff]",
|
||||
"-map",
|
||||
"[diff]",
|
||||
"-vframes",
|
||||
"1",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
subprocess.run(command, check=True, capture_output=True, timeout=60) # nosec B603
|
||||
return Path(output_path)
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
"""AssetStatus 枚举兼容性测试。
|
||||
|
||||
验证历史脏数据(如 'uploaded')不会导致枚举转换失败。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.entities import AssetStatus
|
||||
|
||||
|
||||
class TestAssetStatusNormalValues:
|
||||
"""正常值应该正确映射。"""
|
||||
|
||||
def test_uploading(self):
|
||||
assert AssetStatus("uploading") == AssetStatus.UPLOADING
|
||||
|
||||
def test_ready(self):
|
||||
assert AssetStatus("ready") == AssetStatus.READY
|
||||
|
||||
def test_processing(self):
|
||||
assert AssetStatus("processing") == AssetStatus.PROCESSING
|
||||
|
||||
def test_error(self):
|
||||
assert AssetStatus("error") == AssetStatus.ERROR
|
||||
|
||||
|
||||
class TestAssetStatusHistoricalValues:
|
||||
"""历史脏数据应该正确映射到对应状态,不抛异常。"""
|
||||
|
||||
@pytest.mark.parametrize("value", ["uploaded", "Uploaded", "UPLOADED", " uploaded "])
|
||||
def test_uploaded_maps_to_ready(self, value):
|
||||
"""生产环境发现的 'uploaded' 历史值应映射为 READY。"""
|
||||
assert AssetStatus(value) == AssetStatus.READY
|
||||
|
||||
@pytest.mark.parametrize("value", ["success", "ok", "done", "complete"])
|
||||
def test_other_ready_like_values_map_to_ready(self, value):
|
||||
assert AssetStatus(value) == AssetStatus.READY
|
||||
|
||||
@pytest.mark.parametrize("value", ["upload", "uploading_start", "upload_start"])
|
||||
def test_upload_like_values_map_to_uploading(self, value):
|
||||
assert AssetStatus(value) == AssetStatus.UPLOADING
|
||||
|
||||
@pytest.mark.parametrize("value", ["failed", "fail", "err"])
|
||||
def test_error_like_values_map_to_error(self, value):
|
||||
assert AssetStatus(value) == AssetStatus.ERROR
|
||||
|
||||
@pytest.mark.parametrize("value", ["process", "running", "run"])
|
||||
def test_processing_like_values_map_to_processing(self, value):
|
||||
assert AssetStatus(value) == AssetStatus.PROCESSING
|
||||
|
||||
|
||||
class TestAssetStatusFallback:
|
||||
"""完全未知的值兜底为 READY,不抛500。"""
|
||||
|
||||
@pytest.mark.parametrize("value", ["unknown", "foo_bar", ""])
|
||||
def test_unknown_value_falls_back_to_ready(self, value):
|
||||
assert AssetStatus(value) == AssetStatus.READY
|
||||
|
||||
def test_none_value_falls_back_to_ready(self):
|
||||
assert AssetStatus(None) == AssetStatus.READY # type: ignore[arg-type]
|
||||
|
||||
def test_int_value_falls_back_to_ready(self):
|
||||
assert AssetStatus(123) == AssetStatus.READY # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestAssetStatusStrValue:
|
||||
"""枚举值仍为字符串类型,不影响序列化。"""
|
||||
|
||||
def test_value_unchanged(self):
|
||||
assert AssetStatus.READY.value == "ready"
|
||||
assert AssetStatus.ERROR.value == "error"
|
||||
assert isinstance(AssetStatus.READY, str)
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
"""测试音频URL预签名逻辑。
|
||||
|
||||
验证所有 API 返回的音频 URL 都会经过 OSS 预签名(24小时有效期),
|
||||
确保私有 bucket 下的音频文件前端可正常访问。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAudioUrlSigner:
|
||||
"""测试音频URL签名函数的行为。"""
|
||||
|
||||
def _make_signer(self, mock_storage):
|
||||
"""构造一个签名函数(模拟 get_audio_url_signer 的逻辑)。"""
|
||||
|
||||
def sign_audio_url(url: str) -> str:
|
||||
if not url:
|
||||
return url
|
||||
return mock_storage.get_download_url(url, expires_seconds=86400)
|
||||
|
||||
return sign_audio_url
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
"""空URL直接返回,不调用签名。"""
|
||||
mock_storage = MagicMock()
|
||||
signer = self._make_signer(mock_storage)
|
||||
|
||||
result = signer("")
|
||||
assert result == ""
|
||||
mock_storage.get_download_url.assert_not_called()
|
||||
|
||||
def test_none_url_returns_none(self):
|
||||
"""None URL直接返回(有些字段可能为None)。"""
|
||||
mock_storage = MagicMock()
|
||||
signer = self._make_signer(mock_storage)
|
||||
|
||||
result = signer(None) # type: ignore
|
||||
assert result is None
|
||||
mock_storage.get_download_url.assert_not_called()
|
||||
|
||||
def test_valid_url_gets_signed_24h(self):
|
||||
"""有效URL会调用 storage.get_download_url,有效期24小时(86400秒)。"""
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = (
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3?signature=xxx"
|
||||
)
|
||||
signer = self._make_signer(mock_storage)
|
||||
|
||||
result = signer("https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3")
|
||||
|
||||
assert "signature=xxx" in result
|
||||
mock_storage.get_download_url.assert_called_once_with(
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3",
|
||||
expires_seconds=86400,
|
||||
)
|
||||
|
||||
def test_storage_key_format_also_works(self):
|
||||
"""纯 storage key 格式也能正常签名(storage内部会处理)。"""
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = "https://signed-url/audio.mp3?sig=xxx"
|
||||
signer = self._make_signer(mock_storage)
|
||||
|
||||
result = signer("audio/test.mp3")
|
||||
|
||||
assert result == "https://signed-url/audio.mp3?sig=xxx"
|
||||
mock_storage.get_download_url.assert_called_once_with(
|
||||
"audio/test.mp3",
|
||||
expires_seconds=86400,
|
||||
)
|
||||
|
||||
def test_signer_via_dependencies_module(self):
|
||||
"""通过 dependencies 模块获取 signer,验证集成正确。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
mock_svc = MagicMock(spec=OSSStorageService)
|
||||
mock_svc.get_download_url.return_value = "https://signed/a.mp3?sig=123"
|
||||
|
||||
# 替换全局单例
|
||||
with patch("app.core.storage._storage_service", mock_svc):
|
||||
from app.dependencies import get_audio_url_signer
|
||||
|
||||
signer = get_audio_url_signer()
|
||||
result = signer("test/audio.mp3")
|
||||
|
||||
assert result == "https://signed/a.mp3?sig=123"
|
||||
mock_svc.get_download_url.assert_called_once_with(
|
||||
"test/audio.mp3",
|
||||
expires_seconds=86400,
|
||||
)
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""ClassificationStatus 枚举兼容性测试。
|
||||
|
||||
验证历史脏数据(如 'done')不会导致枚举转换失败。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.entities import ClassificationStatus
|
||||
|
||||
|
||||
class TestClassificationStatusNormalValues:
|
||||
"""正常值应该正确映射。"""
|
||||
|
||||
def test_pending(self):
|
||||
assert ClassificationStatus("pending") == ClassificationStatus.PENDING
|
||||
|
||||
def test_processing(self):
|
||||
assert ClassificationStatus("processing") == ClassificationStatus.PROCESSING
|
||||
|
||||
def test_completed(self):
|
||||
assert ClassificationStatus("completed") == ClassificationStatus.COMPLETED
|
||||
|
||||
def test_failed(self):
|
||||
assert ClassificationStatus("failed") == ClassificationStatus.FAILED
|
||||
|
||||
|
||||
class TestClassificationStatusHistoricalValues:
|
||||
"""历史脏数据应该正确映射到对应状态,不抛异常。"""
|
||||
|
||||
@pytest.mark.parametrize("value", ["done", "Done", "DONE", " done "])
|
||||
def test_done_maps_to_completed(self, value):
|
||||
"""生产环境发现的 'done' 历史值应映射为 COMPLETED。"""
|
||||
assert ClassificationStatus(value) == ClassificationStatus.COMPLETED
|
||||
|
||||
@pytest.mark.parametrize("value", ["success", "finished", "complete"])
|
||||
def test_other_done_like_values_map_to_completed(self, value):
|
||||
assert ClassificationStatus(value) == ClassificationStatus.COMPLETED
|
||||
|
||||
@pytest.mark.parametrize("value", ["fail", "error", "err"])
|
||||
def test_error_like_values_map_to_failed(self, value):
|
||||
assert ClassificationStatus(value) == ClassificationStatus.FAILED
|
||||
|
||||
@pytest.mark.parametrize("value", ["process", "running", "run"])
|
||||
def test_processing_like_values_map_to_processing(self, value):
|
||||
assert ClassificationStatus(value) == ClassificationStatus.PROCESSING
|
||||
|
||||
|
||||
class TestClassificationStatusFallback:
|
||||
"""完全未知的值兜底为 PENDING,不抛500。"""
|
||||
|
||||
@pytest.mark.parametrize("value", ["unknown", "foo_bar", ""])
|
||||
def test_unknown_value_falls_back_to_pending(self, value):
|
||||
assert ClassificationStatus(value) == ClassificationStatus.PENDING
|
||||
|
||||
def test_none_value_falls_back_to_pending(self):
|
||||
assert ClassificationStatus(None) == ClassificationStatus.PENDING # type: ignore[arg-type]
|
||||
|
||||
def test_int_value_falls_back_to_pending(self):
|
||||
assert ClassificationStatus(123) == ClassificationStatus.PENDING # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestClassificationStatusStrValue:
|
||||
"""枚举值仍为字符串类型,不影响序列化。"""
|
||||
|
||||
def test_value_unchanged(self):
|
||||
assert ClassificationStatus.COMPLETED.value == "completed"
|
||||
assert ClassificationStatus.PENDING.value == "pending"
|
||||
assert isinstance(ClassificationStatus.COMPLETED, str)
|
||||
@@ -105,11 +105,19 @@ class TestNormalizePlanConfig:
|
||||
|
||||
|
||||
class TestNormalizeTemplateConfig:
|
||||
def test_same_as_plan_config(self):
|
||||
def test_same_as_plan_config_plus_template_fields(self):
|
||||
"""template config 包含 plan config 的所有字段,外加 transition_enabled"""
|
||||
from packages.domain.config_schemas import normalize_plan_config, normalize_template_config
|
||||
|
||||
raw = {"title": {"text": "模板标题"}}
|
||||
assert normalize_template_config(raw) == normalize_plan_config(raw)
|
||||
plan_cfg = normalize_plan_config(raw)
|
||||
tpl_cfg = normalize_template_config(raw)
|
||||
# plan config 的字段在 template config 中应一致
|
||||
for key in plan_cfg:
|
||||
assert tpl_cfg[key] == plan_cfg[key]
|
||||
# template config 额外包含 transition_enabled
|
||||
assert "transition_enabled" in tpl_cfg
|
||||
assert tpl_cfg["transition_enabled"] is True
|
||||
|
||||
def test_none_returns_defaults(self):
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_TEMPLATE_CONFIG, normalize_template_config
|
||||
|
||||
Regular → Executable
+430
-494
File diff suppressed because it is too large
Load Diff
Regular → Executable
+12
@@ -177,6 +177,18 @@ class StubGenerationTaskRepository:
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return len(
|
||||
[
|
||||
t
|
||||
for t in self._store.values()
|
||||
if t.created_by_user_id == user_id and getattr(t, "status", "") == "pending"
|
||||
]
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return len([t for t in self._store.values() if getattr(t, "status", "") == "pending"])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
|
||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
|
||||
Regular → Executable
+6
@@ -194,6 +194,12 @@ class StubGenerationTaskRepository:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service factory
|
||||
|
||||
Regular → Executable
+2
@@ -63,6 +63,8 @@ class StubEditPlan:
|
||||
template_id: str = "tmpl-001"
|
||||
status: Any = None
|
||||
config: dict = field(default_factory=dict)
|
||||
project_id: str = ""
|
||||
created_by_user_id: str = "user-001"
|
||||
|
||||
def mark_failed(self):
|
||||
self.status = _StubStatus("failed")
|
||||
|
||||
@@ -407,6 +407,7 @@ class TestResponseSchema:
|
||||
"name",
|
||||
"description",
|
||||
"template_type",
|
||||
"editing_mode",
|
||||
"config",
|
||||
"preview_url",
|
||||
"sort_weight",
|
||||
|
||||
Executable
+424
@@ -0,0 +1,424 @@
|
||||
"""Feature Flag 单元测试。
|
||||
|
||||
测试 FeatureFlagConfig、InMemoryFeatureFlagStore、RenderEngineResolver 的核心逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
)
|
||||
|
||||
# ── FeatureFlagConfig 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatureFlagConfig:
|
||||
"""FeatureFlagConfig 核心逻辑测试。"""
|
||||
|
||||
def test_default_disabled(self):
|
||||
"""默认配置为关闭状态。"""
|
||||
config = FeatureFlagConfig(name="test_flag")
|
||||
assert config.enabled is False
|
||||
assert config.percentage == 0
|
||||
assert config.whitelist == set()
|
||||
assert config.is_active() is False
|
||||
assert config.is_active("user1") is False
|
||||
|
||||
def test_global_enabled_100_percent(self):
|
||||
"""100% + 启用 = 全部命中。"""
|
||||
config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=100)
|
||||
assert config.is_active() is True
|
||||
assert config.is_active("user1") is True
|
||||
assert config.is_active("any_user") is True
|
||||
|
||||
def test_global_enabled_0_percent_no_whitelist(self):
|
||||
"""启用但 0% 且无白名单 = 不命中。"""
|
||||
config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=0)
|
||||
assert config.is_active() is False
|
||||
assert config.is_active("user1") is False
|
||||
|
||||
def test_whitelist_takes_priority(self):
|
||||
"""白名单优先级高于百分比。"""
|
||||
config = FeatureFlagConfig(
|
||||
name="test_flag",
|
||||
enabled=True,
|
||||
percentage=0,
|
||||
whitelist={"user1", "user2"},
|
||||
)
|
||||
assert config.is_active("user1") is True
|
||||
assert config.is_active("user2") is True
|
||||
assert config.is_active("user3") is False
|
||||
|
||||
def test_whitelist_with_percentage(self):
|
||||
"""白名单用户即使百分比为0也命中,非白名单按百分比。"""
|
||||
config = FeatureFlagConfig(
|
||||
name="test_flag",
|
||||
enabled=True,
|
||||
percentage=100, # 100% 所有人命中
|
||||
whitelist={"user1"},
|
||||
)
|
||||
assert config.is_active("user1") is True
|
||||
assert config.is_active("user999") is True # 100% 命中
|
||||
|
||||
def test_percentage_consistency_same_user(self):
|
||||
"""同一用户多次调用结果一致(哈希确定性)。"""
|
||||
config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=50)
|
||||
results = [config.is_active("user_fixed") for _ in range(100)]
|
||||
assert all(r == results[0] for r in results)
|
||||
|
||||
def test_percentage_different_users_distributed(self):
|
||||
"""不同用户分布大致符合百分比(统计检验,宽松阈值)。"""
|
||||
config = FeatureFlagConfig(name="test_flag", enabled=True, percentage=50)
|
||||
active_count = sum(1 for i in range(1000) if config.is_active(f"user_{i}"))
|
||||
# 50% 上下浮动 10% 都算合理
|
||||
assert 400 <= active_count <= 600, f"Expected ~500, got {active_count}"
|
||||
|
||||
def test_percentage_boundary_0_and_100(self):
|
||||
"""0% 和 100% 的边界情况。"""
|
||||
config_0 = FeatureFlagConfig(name="test", enabled=True, percentage=0)
|
||||
config_100 = FeatureFlagConfig(name="test", enabled=True, percentage=100)
|
||||
|
||||
for i in range(100):
|
||||
assert config_0.is_active(f"user_{i}") is False
|
||||
assert config_100.is_active(f"user_{i}") is True
|
||||
|
||||
def test_disabled_ignores_all_other_settings(self):
|
||||
"""关闭时忽略白名单和百分比。"""
|
||||
config = FeatureFlagConfig(
|
||||
name="test_flag",
|
||||
enabled=False,
|
||||
percentage=100,
|
||||
whitelist={"user1"},
|
||||
)
|
||||
assert config.is_active("user1") is False
|
||||
assert config.is_active() is False
|
||||
|
||||
def test_none_identifier_with_percentage(self):
|
||||
"""无 identifier 时按随机比例(0% 和 100% 是确定的)。"""
|
||||
config_0 = FeatureFlagConfig(name="test", enabled=True, percentage=0)
|
||||
config_100 = FeatureFlagConfig(name="test", enabled=True, percentage=100)
|
||||
assert config_0.is_active(None) is False
|
||||
assert config_100.is_active(None) is True
|
||||
|
||||
def test_to_dict_and_from_dict(self):
|
||||
"""序列化和反序列化对称。"""
|
||||
original = FeatureFlagConfig(
|
||||
name="test_flag",
|
||||
enabled=True,
|
||||
percentage=30,
|
||||
whitelist={"user_a", "user_b", "user_c"},
|
||||
)
|
||||
data = original.to_dict()
|
||||
restored = FeatureFlagConfig.from_dict(data)
|
||||
assert restored.name == original.name
|
||||
assert restored.enabled == original.enabled
|
||||
assert restored.percentage == original.percentage
|
||||
assert restored.whitelist == original.whitelist
|
||||
|
||||
def test_from_dict_with_missing_fields(self):
|
||||
"""from_dict 缺失字段时使用默认值。"""
|
||||
config = FeatureFlagConfig.from_dict({"name": "minimal"})
|
||||
assert config.name == "minimal"
|
||||
assert config.enabled is False
|
||||
assert config.percentage == 0
|
||||
assert config.whitelist == set()
|
||||
|
||||
|
||||
# ── InMemoryFeatureFlagStore 测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInMemoryFeatureFlagStore:
|
||||
"""内存存储实现测试。"""
|
||||
|
||||
def test_get_nonexistent_returns_default(self):
|
||||
"""获取不存在的 flag 返回默认配置(关闭)。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
config = store.get("nonexistent")
|
||||
assert config.name == "nonexistent"
|
||||
assert config.enabled is False
|
||||
|
||||
def test_set_and_get(self):
|
||||
"""设置后可以读取。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
config = FeatureFlagConfig(name="test", enabled=True, percentage=50, whitelist={"u1"})
|
||||
store.set(config)
|
||||
|
||||
got = store.get("test")
|
||||
assert got.enabled is True
|
||||
assert got.percentage == 50
|
||||
assert got.whitelist == {"u1"}
|
||||
|
||||
def test_delete_existing(self):
|
||||
"""删除存在的 flag 返回 True。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="test", enabled=True))
|
||||
assert store.delete("test") is True
|
||||
assert store.get("test").enabled is False
|
||||
|
||||
def test_delete_nonexistent(self):
|
||||
"""删除不存在的 flag 返回 False。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
assert store.delete("nonexistent") is False
|
||||
|
||||
def test_list_all(self):
|
||||
"""列出所有 flag。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="flag_a", enabled=True))
|
||||
store.set(FeatureFlagConfig(name="flag_b", percentage=10))
|
||||
|
||||
all_flags = store.list_all()
|
||||
assert len(all_flags) == 2
|
||||
assert "flag_a" in all_flags
|
||||
assert "flag_b" in all_flags
|
||||
assert all_flags["flag_a"].enabled is True
|
||||
|
||||
def test_is_active_convenience(self):
|
||||
"""is_active 便捷方法。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render", enabled=True, percentage=0, whitelist={"vip_user"}))
|
||||
assert store.is_active("render", "vip_user") is True
|
||||
assert store.is_active("render", "normal_user") is False
|
||||
assert store.is_active("nonexistent") is False
|
||||
|
||||
|
||||
# ── RenderEngineResolver 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRenderEngineResolver:
|
||||
"""渲染引擎选择器测试。"""
|
||||
|
||||
def test_default_legacy_when_flag_disabled(self):
|
||||
"""flag 关闭时使用默认引擎(legacy)。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
resolver = self._make_resolver(store=store, default="legacy")
|
||||
assert resolver.get_engine() == "legacy"
|
||||
assert resolver.get_engine("user1") == "legacy"
|
||||
|
||||
def test_default_unified_when_flag_disabled(self):
|
||||
"""flag 关闭但默认值是 unified 时返回 unified。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
resolver = self._make_resolver(store=store, default="unified")
|
||||
assert resolver.get_engine() == "unified"
|
||||
|
||||
def test_whitelist_user_uses_unified(self):
|
||||
"""白名单用户走新引擎。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(
|
||||
FeatureFlagConfig(
|
||||
name="render_engine",
|
||||
enabled=True,
|
||||
percentage=0,
|
||||
whitelist={"beta_tester"},
|
||||
)
|
||||
)
|
||||
resolver = self._make_resolver(store=store, default="legacy")
|
||||
assert resolver.get_engine("beta_tester") == "unified"
|
||||
assert resolver.get_engine("normal_user") == "legacy"
|
||||
|
||||
def test_100_percent_all_unified(self):
|
||||
"""100% 时所有用户走新引擎。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100))
|
||||
resolver = self._make_resolver(store=store, default="legacy")
|
||||
for i in range(50):
|
||||
assert resolver.get_engine(f"user_{i}") == "unified"
|
||||
|
||||
def test_invalid_default_engine_fallback(self):
|
||||
"""无效默认值回退到 legacy。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
resolver = self._make_resolver(store=store, default="invalid_value")
|
||||
assert resolver.get_engine() == "legacy"
|
||||
|
||||
def test_should_use_unified_helper(self):
|
||||
"""should_use_unified 便捷方法。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(
|
||||
FeatureFlagConfig(
|
||||
name="render_engine",
|
||||
enabled=True,
|
||||
percentage=0,
|
||||
whitelist={"user_a"},
|
||||
)
|
||||
)
|
||||
resolver = self._make_resolver(store=store)
|
||||
assert resolver.should_use_unified("user_a") is True
|
||||
assert resolver.should_use_unified("user_b") is False
|
||||
|
||||
def test_config_snapshot(self):
|
||||
"""配置快照。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(
|
||||
FeatureFlagConfig(
|
||||
name="render_engine",
|
||||
enabled=True,
|
||||
percentage=30,
|
||||
whitelist={"u1", "u2"},
|
||||
)
|
||||
)
|
||||
resolver = self._make_resolver(store=store)
|
||||
snapshot = resolver.get_config_snapshot()
|
||||
assert snapshot["flag_name"] == "render_engine"
|
||||
assert snapshot["enabled"] is True
|
||||
assert snapshot["percentage"] == 30
|
||||
assert snapshot["whitelist"] == ["u1", "u2"]
|
||||
|
||||
def test_set_flag_updates_config(self):
|
||||
"""通过 set_flag 修改后立即生效。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
resolver = self._make_resolver(store=store, default="legacy")
|
||||
|
||||
# 初始:关闭
|
||||
assert resolver.get_engine("user1") == "legacy"
|
||||
|
||||
# 开启 100%
|
||||
resolver.set_flag(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100))
|
||||
assert resolver.get_engine("user1") == "unified"
|
||||
|
||||
# 关闭
|
||||
resolver.set_flag(FeatureFlagConfig(name="render_engine", enabled=False))
|
||||
assert resolver.get_engine("user1") == "legacy"
|
||||
|
||||
def test_force_refresh(self):
|
||||
"""强制刷新不报错。"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
resolver = self._make_resolver(store=store)
|
||||
resolver.force_refresh() # 不抛异常即可
|
||||
|
||||
def test_does_not_affect_in_flight_tasks(self):
|
||||
"""
|
||||
热更新不影响在途任务验证:
|
||||
任务开始时确定引擎,中途配置变更不改变当前任务的引擎选择。
|
||||
(这是通过"每次调用 get_engine 时读取当前配置"来保证的,
|
||||
任务开始时调用一次拿到结果,之后不再变化)
|
||||
"""
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100))
|
||||
resolver = self._make_resolver(store=store, default="legacy")
|
||||
|
||||
# 模拟任务开始时获取引擎
|
||||
engine_at_start = resolver.get_engine("user1")
|
||||
assert engine_at_start == "unified"
|
||||
|
||||
# 任务进行中关闭 flag
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=False))
|
||||
resolver.force_refresh()
|
||||
|
||||
# 在途任务持有的 engine_at_start 仍然是 unified(不随配置变化)
|
||||
assert engine_at_start == "unified"
|
||||
# 新任务会拿到 legacy
|
||||
assert resolver.get_engine("user1") == "legacy"
|
||||
|
||||
# ── 辅助方法 ──
|
||||
|
||||
@staticmethod
|
||||
def _make_resolver(store=None, default="legacy"):
|
||||
from apps.worker.video_processing.render_engine_resolver import (
|
||||
RenderEngineResolver,
|
||||
)
|
||||
|
||||
return RenderEngineResolver(
|
||||
default_engine=default,
|
||||
store=store or InMemoryFeatureFlagStore(),
|
||||
refresh_interval=9999, # 测试时禁用自动刷新
|
||||
)
|
||||
|
||||
|
||||
# ── RedisFeatureFlagStore 降级测试(无 Redis 环境) ───────────────────────
|
||||
|
||||
|
||||
class TestRedisStoreDegradation:
|
||||
"""Redis 不可用时的降级行为测试。"""
|
||||
|
||||
def test_get_returns_default_when_redis_unavailable(self):
|
||||
"""Redis 连接失败时返回默认关闭配置,不抛异常。"""
|
||||
import importlib
|
||||
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
# 模拟 redis 模块不存在的场景不好做,这里直接测试异常捕获逻辑
|
||||
store = ff_module.RedisFeatureFlagStore.__new__(ff_module.RedisFeatureFlagStore)
|
||||
store._redis = MagicMock()
|
||||
store._redis.hgetall.side_effect = ConnectionError("Redis down")
|
||||
store._key_prefix = ff_module.FEATURE_FLAG_REDIS_PREFIX
|
||||
store._cache = {}
|
||||
store._cache_ttl = 5.0
|
||||
import threading
|
||||
|
||||
store._lock = threading.Lock()
|
||||
|
||||
config = store.get("render_engine")
|
||||
assert config.enabled is False
|
||||
assert config.name == "render_engine"
|
||||
|
||||
def test_list_all_returns_empty_on_redis_error(self):
|
||||
"""Redis 错误时 list_all 返回空字典。"""
|
||||
import importlib
|
||||
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
store = ff_module.RedisFeatureFlagStore.__new__(ff_module.RedisFeatureFlagStore)
|
||||
store._redis = MagicMock()
|
||||
store._redis.scan.side_effect = ConnectionError("Redis down")
|
||||
store._key_prefix = ff_module.FEATURE_FLAG_REDIS_PREFIX
|
||||
store._cache = {}
|
||||
store._cache_ttl = 5.0
|
||||
import threading
|
||||
|
||||
store._lock = threading.Lock()
|
||||
|
||||
result = store.list_all()
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestRedisStoreListAll:
|
||||
"""RedisFeatureFlagStore list_all 正常路径测试。"""
|
||||
|
||||
def _make_store(self):
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
store = ff_module.RedisFeatureFlagStore.__new__(ff_module.RedisFeatureFlagStore)
|
||||
store._redis = MagicMock()
|
||||
store._key_prefix = ff_module.FEATURE_FLAG_REDIS_PREFIX
|
||||
store._cache = {}
|
||||
store._cache_ttl = 5.0
|
||||
import threading
|
||||
|
||||
store._lock = threading.Lock()
|
||||
return store
|
||||
|
||||
def test_list_all_scan_with_match_param(self):
|
||||
"""list_all 调用 redis.scan 时使用正确的 match 参数名。"""
|
||||
store = self._make_store()
|
||||
prefix = store._key_prefix
|
||||
|
||||
# 模拟 scan 返回 2 个 key,分 2 次游标
|
||||
store._redis.scan.side_effect = [
|
||||
(10, [f"{prefix}render_engine", f"{prefix}other_flag"]),
|
||||
(0, []),
|
||||
]
|
||||
# 模拟 hgetall 返回配置
|
||||
store._redis.hgetall.return_value = {
|
||||
b"enabled": b"true",
|
||||
b"percentage": b"50",
|
||||
b"whitelist": b'["user1","user2"]',
|
||||
}
|
||||
|
||||
result = store.list_all()
|
||||
|
||||
# 验证 scan 被调用了 2 次(游标遍历)
|
||||
assert store._redis.scan.call_count == 2
|
||||
# 验证参数名是 match(不是 match_pattern)
|
||||
first_call_kwargs = store._redis.scan.call_args_list[0][1]
|
||||
assert "match" in first_call_kwargs
|
||||
assert "match_pattern" not in first_call_kwargs
|
||||
assert first_call_kwargs["match"] == f"{prefix}*"
|
||||
# 验证返回了 2 个 flag
|
||||
assert len(result) == 2
|
||||
assert "render_engine" in result
|
||||
assert "other_flag" in result
|
||||
@@ -0,0 +1,93 @@
|
||||
"""FFmpeg 超时保护测试。
|
||||
|
||||
验证 run_ffmpeg / probe_video_info 的超时保护机制,
|
||||
防止 FFmpeg hang 住导致 worker 永久阻塞。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.ffmpeg_utils import (
|
||||
DEFAULT_FFMPEG_TIMEOUT,
|
||||
probe_video_info,
|
||||
run_ffmpeg,
|
||||
)
|
||||
|
||||
# ── run_ffmpeg 超时保护 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunFFmpegTimeout:
|
||||
"""run_ffmpeg 超时保护测试。"""
|
||||
|
||||
def test_default_timeout_is_set(self):
|
||||
"""默认超时应为 1800 秒(30分钟)。"""
|
||||
assert DEFAULT_FFMPEG_TIMEOUT == 1800
|
||||
|
||||
def test_timeout_expired_is_raised(self):
|
||||
"""超时未完成时 TimeoutExpired 异常被传播。"""
|
||||
with patch("video_processing.ffmpeg_utils.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["ffmpeg", "test"], timeout=1)
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
run_ffmpeg(["ffmpeg", "test"])
|
||||
|
||||
def test_custom_timeout(self):
|
||||
"""支持自定义超时时间。"""
|
||||
with patch("video_processing.ffmpeg_utils.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["ffmpeg"], timeout=5)
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
run_ffmpeg(["ffmpeg", "test"], timeout=5)
|
||||
|
||||
def test_none_timeout_disables_protection(self):
|
||||
"""timeout=None 可以禁用超时保护(不推荐)。"""
|
||||
with patch("video_processing.ffmpeg_utils.subprocess.run") as mock_run:
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
run_ffmpeg(["ffmpeg", "test"], timeout=None)
|
||||
# 验证 timeout=None 被传递
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["timeout"] is None
|
||||
|
||||
def test_called_process_error_still_raised(self):
|
||||
"""超时异常不影响原有 CalledProcessError 的抛出。"""
|
||||
with patch("video_processing.ffmpeg_utils.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error msg")
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
run_ffmpeg(["ffmpeg", "test"])
|
||||
|
||||
|
||||
# ── probe_video_info 超时保护 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProbeVideoInfoTimeout:
|
||||
"""probe_video_info 超时保护测试。"""
|
||||
|
||||
def test_probe_uses_timeout(self):
|
||||
"""probe_video_info 调用 ffprobe 时应设置 timeout=15。"""
|
||||
with patch("video_processing.ffmpeg_utils.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["ffprobe"], timeout=15)
|
||||
# 超时异常被捕获,返回默认值
|
||||
result = probe_video_info("/tmp/test.mp4")
|
||||
assert result["width"] == 1280 # DEFAULT_OUTPUT_WIDTH
|
||||
assert result["height"] == 720 # DEFAULT_OUTPUT_HEIGHT
|
||||
|
||||
def test_probe_success(self):
|
||||
"""正常情况应解析 ffprobe JSON 输出。"""
|
||||
fake_output = """
|
||||
{
|
||||
"streams": [{"width": 1920, "height": 1080, "codec_type": "video", "r_frame_rate": "30/1", "duration": "10.5"}],
|
||||
"format": {"duration": "10.5"}
|
||||
}
|
||||
"""
|
||||
with patch("video_processing.ffmpeg_utils.subprocess.run") as mock_run:
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = fake_output
|
||||
mock_run.return_value = mock_result
|
||||
result = probe_video_info("/tmp/test.mp4")
|
||||
assert result["width"] == 1920
|
||||
assert result["height"] == 1080
|
||||
assert abs(result["duration"] - 10.5) < 0.01
|
||||
@@ -0,0 +1,338 @@
|
||||
"""generate_video 任务 Feature Flag 灰度引擎选择单元测试.
|
||||
|
||||
覆盖:
|
||||
- _resolve_render_engine 正常返回 unified / legacy
|
||||
- Feature Flag 不可用时 fallback 到 unified
|
||||
- 白名单 / 百分比 / 全局开关各场景
|
||||
- _render_with_legacy_engine 命令构建与输出验证
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from types import ModuleType
|
||||
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")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
_mock_db_mod = ModuleType("worker_app.db")
|
||||
_mock_db_mod.SessionLocal = MagicMock()
|
||||
sys.modules.setdefault("worker_app.db", _mock_db_mod)
|
||||
|
||||
_mock_celery_mod = ModuleType("worker_app.celery_app")
|
||||
_mock_celery_app = MagicMock()
|
||||
_mock_celery_app.task = lambda **kwargs: lambda fn: fn
|
||||
_mock_celery_mod.celery_app = _mock_celery_app
|
||||
sys.modules.setdefault("worker_app.celery_app", _mock_celery_mod)
|
||||
|
||||
# Mock worker_app.core.config 避免 settings 加载
|
||||
_mock_config_mod = ModuleType("worker_app.core.config")
|
||||
_mock_settings = MagicMock()
|
||||
_mock_settings.redis_url = None
|
||||
_mock_settings.render_engine = "unified"
|
||||
_mock_config_mod.get_settings = lambda: _mock_settings
|
||||
sys.modules.setdefault("worker_app.core", ModuleType("worker_app.core"))
|
||||
sys.modules.setdefault("worker_app.core.config", _mock_config_mod)
|
||||
|
||||
|
||||
# ── 测试用数据类 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _TestClip:
|
||||
def __init__(self, asset_id, duration=30.0, clip_type="main", config=None, order=0):
|
||||
self.id = f"clip_{asset_id}"
|
||||
self.plan_id = "test-plan"
|
||||
self.clip_type = clip_type
|
||||
self.order = order
|
||||
self.asset_id = asset_id
|
||||
self.duration = duration
|
||||
self.config = config or {}
|
||||
self.start_time = 0.0
|
||||
self.transition_effect = "cut"
|
||||
|
||||
|
||||
# ── RenderEngineResolver 基础行为测试 ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_resolver_unified_when_enabled_100_percent():
|
||||
"""flag 全局开启(percentage=100)时,返回 unified。"""
|
||||
from video_processing.render_engine_resolver import RenderEngineResolver
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
)
|
||||
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100))
|
||||
resolver = RenderEngineResolver(default_engine="legacy", store=store)
|
||||
|
||||
assert resolver.get_engine(user_id="user-123") == "unified"
|
||||
|
||||
|
||||
def test_resolver_legacy_when_flag_disabled():
|
||||
"""flag 全局关闭时,返回默认引擎 legacy。"""
|
||||
from video_processing.render_engine_resolver import RenderEngineResolver
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
)
|
||||
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=False, percentage=100))
|
||||
resolver = RenderEngineResolver(default_engine="legacy", store=store)
|
||||
|
||||
assert resolver.get_engine(user_id="user-123") == "legacy"
|
||||
|
||||
|
||||
def test_resolver_whitelist_overrides_percentage_0():
|
||||
"""白名单用户即使 percentage=0 也走 unified。"""
|
||||
from video_processing.render_engine_resolver import RenderEngineResolver
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
)
|
||||
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(
|
||||
FeatureFlagConfig(
|
||||
name="render_engine",
|
||||
enabled=True,
|
||||
percentage=0,
|
||||
whitelist={"user-vip"},
|
||||
)
|
||||
)
|
||||
resolver = RenderEngineResolver(default_engine="legacy", store=store)
|
||||
|
||||
assert resolver.get_engine(user_id="user-vip") == "unified"
|
||||
assert resolver.get_engine(user_id="user-other") == "legacy"
|
||||
|
||||
|
||||
def test_resolver_percentage_0_all_legacy():
|
||||
"""percentage=0 且无白名单时,全部走 legacy。"""
|
||||
from video_processing.render_engine_resolver import RenderEngineResolver
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
)
|
||||
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=0))
|
||||
resolver = RenderEngineResolver(default_engine="legacy", store=store)
|
||||
|
||||
for i in range(50):
|
||||
assert resolver.get_engine(user_id=f"user-{i}") == "legacy"
|
||||
|
||||
|
||||
def test_resolver_default_unified_when_flag_off():
|
||||
"""默认引擎设为 unified 且 flag 关闭时,返回 unified。"""
|
||||
from video_processing.render_engine_resolver import RenderEngineResolver
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
)
|
||||
|
||||
store = InMemoryFeatureFlagStore()
|
||||
store.set(FeatureFlagConfig(name="render_engine", enabled=False, percentage=0))
|
||||
resolver = RenderEngineResolver(default_engine="unified", store=store)
|
||||
|
||||
assert resolver.get_engine(user_id="user-123") == "unified"
|
||||
|
||||
|
||||
# ── _render_with_legacy_engine 集成测试 ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_legacy_engine_single_clip_keeps_original_fps():
|
||||
"""单 clip 场景:输出保持原帧率(不做 fps 归一化),分辨率缩放正确。"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from video_processing.ffmpeg_utils import probe_video_info
|
||||
|
||||
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
input_path = tmp_path / "input.mp4"
|
||||
output_path = tmp_path / "output.mp4"
|
||||
|
||||
# 生成 1 秒 30fps 测试视频(带音频)
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=640x360:d=1:r=30",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=r=44100:cl=stereo:d=1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(input_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
clip = _TestClip(asset_id="asset-1", duration=1.0)
|
||||
asset_path_map = {"asset-1": input_path}
|
||||
|
||||
duration, file_size = _render_with_legacy_engine(
|
||||
task_id="test-task",
|
||||
virtual_clips=[clip],
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=tmp_path,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
assert output_path.exists()
|
||||
assert file_size > 0
|
||||
assert duration > 0
|
||||
|
||||
# 旧引擎保持原帧率(30fps),不做 fps 归一化
|
||||
info = probe_video_info(str(output_path))
|
||||
assert abs(info.get("fps", 0) - 30.0) < 0.5
|
||||
assert info.get("width") == 1280
|
||||
assert info.get("height") == 720
|
||||
|
||||
|
||||
def test_legacy_engine_two_clips_concat_duration():
|
||||
"""多 clip 场景:concat 后时长为两片段之和。"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
|
||||
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
input1 = tmp_path / "input1.mp4"
|
||||
input2 = tmp_path / "input2.mp4"
|
||||
output_path = tmp_path / "output.mp4"
|
||||
|
||||
for idx, inp in enumerate([input1, input2]):
|
||||
color = "red" if idx == 0 else "blue"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c={color}:s=640x360:d=1:r=30",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=r=44100:cl=stereo:d=1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(inp),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
clip1 = _TestClip(asset_id="asset-1", duration=1.0, clip_type="main", order=0)
|
||||
clip2 = _TestClip(asset_id="asset-2", duration=1.0, clip_type="main", order=1)
|
||||
asset_path_map = {"asset-1": input1, "asset-2": input2}
|
||||
|
||||
duration, file_size = _render_with_legacy_engine(
|
||||
task_id="test-task",
|
||||
virtual_clips=[clip1, clip2],
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=tmp_path,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
assert output_path.exists()
|
||||
assert file_size > 0
|
||||
assert abs(duration - 2.0) < 0.2
|
||||
|
||||
|
||||
def test_legacy_engine_broll_mode_supported():
|
||||
"""b_roll 类型的 clip 也被正确识别为主图层并渲染。"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
input_path = tmp_path / "input.mp4"
|
||||
output_path = tmp_path / "output.mp4"
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=green:s=640x360:d=1:r=30",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=r=44100:cl=stereo:d=1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(input_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
clip = _TestClip(
|
||||
asset_id="asset-1",
|
||||
duration=1.0,
|
||||
clip_type="main",
|
||||
config={"role": "b_roll"},
|
||||
)
|
||||
asset_path_map = {"asset-1": input_path}
|
||||
|
||||
duration, file_size = _render_with_legacy_engine(
|
||||
task_id="test-task",
|
||||
virtual_clips=[clip],
|
||||
asset_path_map=asset_path_map,
|
||||
work_dir=tmp_path,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
assert output_path.exists()
|
||||
assert file_size > 0
|
||||
assert duration > 0
|
||||
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
一键生成链路日志最小集 单元测试
|
||||
|
||||
覆盖:
|
||||
- GenerationTask.append_log() 正确追加结构化日志
|
||||
- GenerationTask.append_log() 超过 200 条时截断
|
||||
- GenerationTask.get_logs() 正确解析 JSON
|
||||
- GenerationTask.get_logs() 异常 JSON 不抛异常
|
||||
- GenerationTaskResponse logs 字段 validator 解析 JSON 字符串
|
||||
- GenerationTaskResponse logs 字段 validator 处理非法输入
|
||||
- Worker 日志格式 [task_id=xxx] [阶段] 消息
|
||||
- _flush_logs 异常不抛出
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
|
||||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||||
|
||||
|
||||
def _make_task(**kwargs) -> GenerationTask:
|
||||
"""创建测试用 GenerationTask。"""
|
||||
defaults = {
|
||||
"id": "task-001",
|
||||
"project_id": "proj-001",
|
||||
"asset_library_id": "lib-001",
|
||||
"strategy_id": "one_take",
|
||||
"voice_library_id": "",
|
||||
"template_id": "tpl-001",
|
||||
"asset_ids": ["asset-1", "asset-2"],
|
||||
"title_ids": [],
|
||||
"voice_ids": [],
|
||||
"status": GenerationTaskStatus.PENDING,
|
||||
"progress": 0.0,
|
||||
"result_count": 0,
|
||||
"error_message": "",
|
||||
"created_by_user_id": "user-001",
|
||||
"source_edit_plan_id": "",
|
||||
"asset_select_mode": "all",
|
||||
"batch_id": "",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return GenerationTask(**defaults)
|
||||
|
||||
|
||||
class TestAppendLog:
|
||||
"""GenerationTask.append_log() 单元测试。"""
|
||||
|
||||
def test_append_single_log(self):
|
||||
task = _make_task()
|
||||
task.append_log("接收任务", "任务开始", mode="one_take")
|
||||
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
entry = logs[0]
|
||||
assert entry["level"] == "INFO"
|
||||
assert entry["stage"] == "接收任务"
|
||||
assert entry["message"] == "任务开始"
|
||||
assert entry["mode"] == "one_take"
|
||||
assert "ts" in entry
|
||||
|
||||
def test_append_multiple_logs(self):
|
||||
task = _make_task()
|
||||
task.append_log("接收任务", "任务开始")
|
||||
task.append_log("下载素材", "下载完成", count=3)
|
||||
task.append_log("渲染", "渲染完成", duration=12.5)
|
||||
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 3
|
||||
assert logs[0]["stage"] == "接收任务"
|
||||
assert logs[1]["stage"] == "下载素材"
|
||||
assert logs[1]["count"] == 3
|
||||
assert logs[2]["stage"] == "渲染"
|
||||
assert logs[2]["duration"] == 12.5
|
||||
|
||||
def test_append_log_with_error_level(self):
|
||||
task = _make_task()
|
||||
task.append_log("任务失败", "OSS上传失败", level="ERROR", error_type="RuntimeError")
|
||||
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["level"] == "ERROR"
|
||||
assert logs[0]["error_type"] == "RuntimeError"
|
||||
|
||||
def test_append_log_truncates_at_200(self):
|
||||
task = _make_task()
|
||||
for i in range(250):
|
||||
task.append_log("阶段", f"消息{i}")
|
||||
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 200
|
||||
# 保留最后 200 条
|
||||
assert logs[0]["message"] == "消息50"
|
||||
assert logs[-1]["message"] == "消息249"
|
||||
|
||||
def test_append_log_handles_corrupted_json(self):
|
||||
task = _make_task(logs="not-valid-json")
|
||||
task.append_log("接收任务", "任务开始")
|
||||
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["message"] == "任务开始"
|
||||
|
||||
def test_append_log_handles_empty_string(self):
|
||||
task = _make_task(logs="")
|
||||
task.append_log("接收任务", "任务开始")
|
||||
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
|
||||
|
||||
class TestGetLogs:
|
||||
"""GenerationTask.get_logs() 单元测试。"""
|
||||
|
||||
def test_get_logs_empty(self):
|
||||
task = _make_task()
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_get_logs_parses_json(self):
|
||||
entries = [{"ts": "2026-01-01T00:00:00Z", "level": "INFO", "stage": "test", "message": "hello"}]
|
||||
task = _make_task(logs=json.dumps(entries, ensure_ascii=False))
|
||||
logs = task.get_logs()
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["message"] == "hello"
|
||||
|
||||
def test_get_logs_handles_invalid_json(self):
|
||||
task = _make_task(logs="{broken")
|
||||
assert task.get_logs() == []
|
||||
|
||||
def test_get_logs_handles_none(self):
|
||||
task = _make_task(logs=None)
|
||||
assert task.get_logs() == []
|
||||
|
||||
|
||||
class TestGenerationTaskResponseLogs:
|
||||
"""GenerationTaskResponse logs 字段 validator 测试。"""
|
||||
|
||||
def _make_response_data(self, logs_value) -> dict:
|
||||
return {
|
||||
"id": "task-001",
|
||||
"project_id": "proj-001",
|
||||
"asset_library_id": "lib-001",
|
||||
"strategy_id": "one_take",
|
||||
"voice_library_id": "",
|
||||
"template_id": "",
|
||||
"asset_ids": [],
|
||||
"title_ids": [],
|
||||
"voice_ids": [],
|
||||
"source_edit_plan_id": "",
|
||||
"asset_select_mode": "all",
|
||||
"batch_id": "",
|
||||
"status": "completed",
|
||||
"progress": 1.0,
|
||||
"result_count": 1,
|
||||
"error_message": "",
|
||||
"logs": logs_value,
|
||||
}
|
||||
|
||||
def test_logs_json_string_parsed(self):
|
||||
entries = [{"ts": "2026-01-01T00:00:00Z", "level": "INFO", "stage": "test", "message": "ok"}]
|
||||
data = self._make_response_data(json.dumps(entries, ensure_ascii=False))
|
||||
resp = GenerationTaskResponse(**data)
|
||||
assert isinstance(resp.logs, list)
|
||||
assert len(resp.logs) == 1
|
||||
assert resp.logs[0]["message"] == "ok"
|
||||
|
||||
def test_logs_list_passthrough(self):
|
||||
entries = [{"ts": "2026-01-01T00:00:00Z", "level": "INFO", "stage": "test", "message": "ok"}]
|
||||
data = self._make_response_data(entries)
|
||||
resp = GenerationTaskResponse(**data)
|
||||
assert resp.logs == entries
|
||||
|
||||
def test_logs_invalid_json_returns_empty(self):
|
||||
data = self._make_response_data("{broken")
|
||||
resp = GenerationTaskResponse(**data)
|
||||
assert resp.logs == []
|
||||
|
||||
def test_logs_empty_string_returns_empty(self):
|
||||
data = self._make_response_data("")
|
||||
resp = GenerationTaskResponse(**data)
|
||||
assert resp.logs == []
|
||||
|
||||
def test_logs_default_empty(self):
|
||||
data = self._make_response_data("[]")
|
||||
resp = GenerationTaskResponse(**data)
|
||||
assert resp.logs == []
|
||||
|
||||
|
||||
class TestWorkerLogFormat:
|
||||
"""Worker 日志格式 [task_id=xxx] [阶段] 消息 测试。"""
|
||||
|
||||
def test_log_format_pattern(self):
|
||||
"""验证日志格式匹配 [task_id=xxx] [阶段] 消息。"""
|
||||
import re
|
||||
|
||||
task_id = "abc123"
|
||||
stage = "下载素材"
|
||||
message = "完成: 成功=3个, 耗时=1.5s"
|
||||
formatted = f"[task_id={task_id}] [{stage}] {message}"
|
||||
|
||||
pattern = r"^\[task_id=[\w-]+\] \[.+\] .+$"
|
||||
assert re.match(pattern, formatted)
|
||||
|
||||
def test_log_entries_contain_required_fields(self):
|
||||
"""验证 append_log 生成的条目包含所有必需字段。"""
|
||||
task = _make_task()
|
||||
task.append_log("OSS上传", "上传成功", file_size=1024000, duration=2.5)
|
||||
|
||||
logs = task.get_logs()
|
||||
entry = logs[0]
|
||||
assert "ts" in entry
|
||||
assert "level" in entry
|
||||
assert "stage" in entry
|
||||
assert "message" in entry
|
||||
assert entry["file_size"] == 1024000
|
||||
assert entry["duration"] == 2.5
|
||||
|
||||
|
||||
class TestFlushLogs:
|
||||
"""_flush_logs 异常安全测试。"""
|
||||
|
||||
def test_flush_logs_exception_not_raised(self):
|
||||
"""_flush_logs 在 DB 异常时不应抛出。"""
|
||||
# 模拟 worker 环境
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
|
||||
from worker_app.tasks.generation import _flush_logs
|
||||
|
||||
task = _make_task()
|
||||
task.append_log("测试", "消息")
|
||||
|
||||
# Mock SessionLocal 抛异常
|
||||
with patch("worker_app.tasks.generation.SessionLocal", side_effect=RuntimeError("DB error")):
|
||||
# 不应抛出
|
||||
_flush_logs("task-001", task)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""P3 优化单元测试 — generation.py 三项优化.
|
||||
|
||||
覆盖:
|
||||
P3-1: _download_library_assets strict 模式
|
||||
P3-2: 归属校验合并到同一 DB session
|
||||
P3-3: _verify_url_accessible HEAD 重试
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
|
||||
|
||||
# ── P3-3: _verify_url_accessible 重试 ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyUrlAccessibleRetry:
|
||||
"""_verify_url_accessible 重试逻辑."""
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_first_attempt_success(self, mock_urlopen, mock_sleep):
|
||||
"""首次成功,不重试."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is True
|
||||
assert mock_urlopen.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_retry_then_success(self, mock_urlopen, mock_sleep):
|
||||
"""首次失败,重试后成功."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
# 第一次失败(网络异常),第二次成功
|
||||
mock_resp_ok = MagicMock()
|
||||
mock_resp_ok.status = 200
|
||||
mock_resp_ok.__enter__ = MagicMock(return_value=mock_resp_ok)
|
||||
mock_resp_ok.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_urlopen.side_effect = [
|
||||
OSError("connection reset"),
|
||||
mock_resp_ok,
|
||||
]
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is True
|
||||
assert mock_urlopen.call_count == 2
|
||||
mock_sleep.assert_called_once_with(1)
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_all_retries_exhausted(self, mock_urlopen, mock_sleep):
|
||||
"""全部重试耗尽,返回 False."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
mock_urlopen.side_effect = OSError("connection refused")
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is False
|
||||
# 1 首次 + 2 重试 = 3 次
|
||||
assert mock_urlopen.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_http_500_then_success(self, mock_urlopen, mock_sleep):
|
||||
"""HTTP 500 后重试成功."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
mock_resp_500 = MagicMock()
|
||||
mock_resp_500.status = 500
|
||||
mock_resp_500.__enter__ = MagicMock(return_value=mock_resp_500)
|
||||
mock_resp_500.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_resp_200 = MagicMock()
|
||||
mock_resp_200.status = 200
|
||||
mock_resp_200.__enter__ = MagicMock(return_value=mock_resp_200)
|
||||
mock_resp_200.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_urlopen.side_effect = [mock_resp_500, mock_resp_200]
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4") is True
|
||||
assert mock_urlopen.call_count == 2
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_custom_retries_zero(self, mock_urlopen, mock_sleep):
|
||||
"""retries=0 时不重试."""
|
||||
from worker_app.tasks.generation import _verify_url_accessible
|
||||
|
||||
mock_urlopen.side_effect = OSError("timeout")
|
||||
|
||||
assert _verify_url_accessible("https://example.com/file.mp4", retries=0) is False
|
||||
assert mock_urlopen.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
# ── P3-1: _download_library_assets strict 模式 ──────────────────────────────
|
||||
|
||||
|
||||
def _make_mock_asset(
|
||||
asset_id: str,
|
||||
name: str,
|
||||
file_url: str | None,
|
||||
asset_library_id: str = "lib-1",
|
||||
project_id: str = "",
|
||||
):
|
||||
"""构造 mock AssetModel 实例."""
|
||||
return SimpleNamespace(
|
||||
id=asset_id,
|
||||
name=name,
|
||||
file_url=file_url,
|
||||
asset_library_id=asset_library_id,
|
||||
project_id=project_id,
|
||||
status="ready",
|
||||
file_type="video",
|
||||
created_at="2026-01-01",
|
||||
)
|
||||
|
||||
|
||||
def _setup_mock_session(assets):
|
||||
"""构造 mock session,返回 (mock_session, mock_query_chain)."""
|
||||
mock_session = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
|
||||
# chain: session.query().filter().filter().order_by().all()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = assets
|
||||
|
||||
return mock_session
|
||||
|
||||
|
||||
class TestDownloadLibraryAssetsStrictMode:
|
||||
"""_download_library_assets strict 模式."""
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_strict_mode_raises_on_download_failure(self, mock_download, mock_session_factory):
|
||||
"""strict=True 时,单个素材下载失败立即抛 RuntimeError."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
|
||||
_make_mock_asset("a2", "video2.mp4", "uploads/video2.mp4"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
# 第一个成功,第二个失败
|
||||
mock_download.side_effect = [True, False]
|
||||
|
||||
with pytest.raises(RuntimeError, match="素材下载失败"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
strict=True,
|
||||
)
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_non_strict_mode_returns_partial_results(self, mock_download, mock_session_factory):
|
||||
"""strict=False 时,跳过失败素材,返回成功列表."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
|
||||
_make_mock_asset("a2", "video2.mp4", "uploads/video2.mp4"),
|
||||
_make_mock_asset("a3", "video3.mp4", "uploads/video3.mp4"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
# 第一个成功,第二个失败,第三个成功
|
||||
mock_download.side_effect = [True, False, True]
|
||||
|
||||
result = _download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["a1", "a2", "a3"],
|
||||
strict=False,
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_non_strict_all_fail_raises(self, mock_download, mock_session_factory):
|
||||
"""strict=False 但全部失败时仍抛 RuntimeError."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
mock_download.return_value = False
|
||||
|
||||
with pytest.raises(RuntimeError, match="全部下载失败"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["a1"],
|
||||
strict=False,
|
||||
)
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_strict_mode_raises_on_missing_file_url(self, mock_download, mock_session_factory):
|
||||
"""strict=True 时,素材缺少 file_url 立即抛异常."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", None), # file_url 为空
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
with pytest.raises(RuntimeError, match="素材缺少 file_url"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
strict=True,
|
||||
)
|
||||
|
||||
# download_asset 不应被调用
|
||||
mock_download.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_default_is_strict(self, mock_download, mock_session_factory):
|
||||
"""默认 strict=True."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
mock_download.return_value = False
|
||||
|
||||
# 不传 strict 参数,默认严格模式
|
||||
with pytest.raises(RuntimeError, match="素材下载失败"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
)
|
||||
|
||||
|
||||
# ── P3-2: 归属校验合并到同一 session ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDownloadLibraryAssetsOwnershipValidation:
|
||||
"""归属校验合并到 _download_library_assets 同一 session."""
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_ownership_mismatch_raises_value_error(self, mock_download, mock_session_factory):
|
||||
"""asset_ids 不属于指定素材库时抛 ValueError."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
# asset 属于 lib-2,但请求的是 lib-1
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4", asset_library_id="lib-2"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
with pytest.raises(ValueError, match="素材不属于指定素材库"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["a1"],
|
||||
)
|
||||
|
||||
# 不应调用 download_asset(校验在下载前)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_missing_asset_ids_raises_value_error(self, mock_download, mock_session_factory):
|
||||
"""指定的 asset_ids 不存在时抛 ValueError."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
# DB 返回空(asset_ids 不存在,query 过滤后无结果)
|
||||
mock_session = _setup_mock_session([])
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
with pytest.raises(RuntimeError, match="未找到视频素材"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["nonexistent-id"],
|
||||
)
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_project_ownership_mismatch_raises(self, mock_download, mock_session_factory):
|
||||
"""项目级模式下归属不匹配抛 ValueError."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset(
|
||||
"a1",
|
||||
"video1.mp4",
|
||||
"uploads/video1.mp4",
|
||||
asset_library_id="",
|
||||
project_id="proj-2",
|
||||
),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
with pytest.raises(ValueError, match="素材不属于指定项目"):
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
project_id="proj-1",
|
||||
asset_ids=["a1"],
|
||||
)
|
||||
|
||||
mock_download.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_ownership_pass_then_download(self, mock_download, mock_session_factory):
|
||||
"""归属校验通过后正常下载."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4", asset_library_id="lib-1"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
mock_download.return_value = True
|
||||
|
||||
result = _download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["a1"],
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
mock_download.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.generation.SessionLocal")
|
||||
@patch("worker_app.tasks.generation.download_asset")
|
||||
def test_single_session_used(self, mock_download, mock_session_factory):
|
||||
"""验证只创建了一个 DB session(P3-2 核心)."""
|
||||
from worker_app.tasks.generation import _download_library_assets
|
||||
|
||||
assets = [
|
||||
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4", asset_library_id="lib-1"),
|
||||
]
|
||||
mock_session = _setup_mock_session(assets)
|
||||
mock_session_factory.return_value = mock_session
|
||||
mock_download.return_value = True
|
||||
|
||||
_download_library_assets(
|
||||
Path("/tmp"),
|
||||
asset_library_id="lib-1",
|
||||
asset_ids=["a1"],
|
||||
)
|
||||
|
||||
# SessionLocal 只调用一次(合并前会调用两次:校验 + 下载)
|
||||
assert mock_session_factory.call_count == 1
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
P0-2 修复测试:generation_tasks results 端点返回 OSS 预签名 URL
|
||||
|
||||
验证 list_generation_results 端点:
|
||||
- 对每个生成视频的 file_url 调用 storage_service.get_download_url()
|
||||
- 返回的 download_url 是预签名临时 URL(24h 有效期)
|
||||
- 与 generated_videos.py 中的模式一致
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain import (
|
||||
GeneratedVideo,
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects=None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def find_by_id(self, project_id):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
def __init__(self, tasks=None):
|
||||
self._tasks = tasks or {}
|
||||
|
||||
def get(self, task_id):
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def count_pending_by_user(self, user_id):
|
||||
return 0
|
||||
|
||||
def count_pending_total(self):
|
||||
return 0
|
||||
|
||||
|
||||
class StubGeneratedVideoRepository:
|
||||
def __init__(self, videos=None):
|
||||
self._videos = videos or {}
|
||||
|
||||
def list_by_generation_task(self, task_id):
|
||||
return [v for v in self._videos.values() if v.generation_task_id == task_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app(task, videos, storage_mock):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/generation")
|
||||
|
||||
user = User(id="user-1", email="test@test.com", display_name="Test", username="testuser")
|
||||
auth = AuthenticatedUser(user=user, token_type="access")
|
||||
|
||||
project = Project(id="project-1", name="Test", description="", owner_user_id="user-1")
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: auth
|
||||
app.dependency_overrides[get_project_repository] = lambda: StubProjectRepository({"project-1": project})
|
||||
app.dependency_overrides[get_generation_task_repository] = lambda: StubGenerationTaskRepository({"task-1": task})
|
||||
app.dependency_overrides[get_generated_video_repository] = lambda: StubGeneratedVideoRepository(videos)
|
||||
app.dependency_overrides[get_storage_service] = lambda: storage_mock
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_results_endpoint_generates_presigned_urls():
|
||||
"""验证 list_generation_results 为每个视频生成预签名 download_url"""
|
||||
task = GenerationTask(
|
||||
id="task-1",
|
||||
project_id="project-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="s1",
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
progress=100,
|
||||
result_count=2,
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
|
||||
videos = {
|
||||
"v1": GeneratedVideo.create(
|
||||
project_id="project-1",
|
||||
generation_task_id="task-1",
|
||||
name="video1.mp4",
|
||||
file_url="https://bucket.oss-cn-hangzhou.aliyuncs.com/generated/v1.mp4",
|
||||
file_size=1024,
|
||||
duration=5.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
),
|
||||
"v2": GeneratedVideo.create(
|
||||
project_id="project-1",
|
||||
generation_task_id="task-1",
|
||||
name="video2.mp4",
|
||||
file_url="https://bucket.oss-cn-hangzhou.aliyuncs.com/generated/v2.mp4",
|
||||
file_size=2048,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=30.0,
|
||||
),
|
||||
}
|
||||
|
||||
storage_mock = MagicMock(spec=OSSStorageService)
|
||||
storage_mock.get_download_url.side_effect = (
|
||||
lambda url, expires_seconds=3600: f"{url}?signature=presigned&expires={expires_seconds}"
|
||||
)
|
||||
|
||||
app = _make_app(task, videos, storage_mock)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/generation/tasks/task-1/results")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
# Verify presigned URLs were generated with 24h expiry
|
||||
assert storage_mock.get_download_url.call_count == 2
|
||||
for call in storage_mock.get_download_url.call_args_list:
|
||||
assert call.kwargs["expires_seconds"] == 86400
|
||||
|
||||
# Verify download_url is present in response
|
||||
for item in data["items"]:
|
||||
assert item["download_url"] is not None
|
||||
assert "signature=presigned" in item["download_url"]
|
||||
assert "expires=86400" in item["download_url"]
|
||||
|
||||
# Verify file_url is still the original (raw) URL
|
||||
assert data["items"][0]["file_url"] == videos["v1"].file_url
|
||||
|
||||
|
||||
def test_results_endpoint_handles_empty_videos():
|
||||
"""验证无视频时正常返回空列表"""
|
||||
task = GenerationTask(
|
||||
id="task-1",
|
||||
project_id="project-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="s1",
|
||||
status=GenerationTaskStatus.RUNNING,
|
||||
progress=50,
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
|
||||
storage_mock = MagicMock(spec=OSSStorageService)
|
||||
app = _make_app(task, {}, storage_mock)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/generation/tasks/task-1/results")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == []
|
||||
storage_mock.get_download_url.assert_not_called()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user