Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 1b2fe95854 Merge branch 'develop' into fix/smart-match-empty-results
AI Code Review / AI Code Review (pull_request) Successful in 2m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m21s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m20s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m53s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m0s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
2026-09-01 12:31:18 +08:00
125 changed files with 848 additions and 9364 deletions
-1
View File
@@ -1 +0,0 @@
CI re-trigger after runner add-host/DNS fix. This file is harmless and not referenced.
-105
View File
@@ -1,105 +0,0 @@
name: CI Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements-dev.txt'
- 'infra/docker/ci.Dockerfile'
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - ci-base 镜像重建"
concurrency:
group: ci-base-image-build
cancel-in-progress: false
jobs:
build-ci-base:
name: Build CI Base Image
runs-on: runtime-builder
timeout-minutes: 60
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Gitea Registry
shell: sh
env:
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Build and push CI base image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
docker build --progress=plain \
-f infra/docker/ci.Dockerfile \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${VERSION_TAG}" \
.
echo "✅ Image built successfully"
echo "=== Pushing ${VERSION_TAG} ==="
docker push "${IMAGE}:${VERSION_TAG}"
echo "=== Pushing latest ==="
docker push "${IMAGE}:latest"
echo "✅ Pushed to Gitea Registry"
- name: Verify image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
echo "=== Verifying pinned deps in fresh image ==="
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
- name: Notify result
if: always()
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ "${{ job.status }}" = "success" ]; then
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
else
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
fi
- name: Cleanup
if: always()
shell: sh
run: |
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
docker rmi "${IMAGE}:latest" 2>/dev/null || true
echo "Cleanup done"
-51
View File
@@ -1,51 +0,0 @@
name: CI Canary Check
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
jobs:
canary:
runs-on: ci-l2
timeout-minutes: 10
steps:
- name: Canary (runner -> docker -> network -> gitea)
run: |
set -e
echo "== runner/container basic =="
date; hostname; whoami
echo "== gitea api reachability =="
code=$(curl -s -o /tmp/v.json -w '%{http_code}' -m 15 "$GITHUB_API_URL/version")
echo "gitea api http_code=$code"
[ "$code" = "200" ] || { echo "::error::Gitea API unreachable, http_code=$code"; exit 1; }
cat /tmp/v.json; echo
echo "== external egress =="
ext=$(curl -s -o /dev/null -w '%{http_code}' -m 15 https://www.baidu.com || echo 000)
echo "external http_code=$ext"
echo "== gitea domain resolves NOT to loopback =="
set -o pipefail
ip=$(getent hosts git.xiaoxiajianji.com | awk '{print $1}' | head -1)
echo "git.xiaoxiajianji.com -> $ip"
if [ -z "$ip" ]; then
echo "::error::DNS resolution failed, git.xiaoxiajianji.com unresolvable"; exit 1
fi
if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
echo "::error::Gitea domain resolves to loopback inside job container (hosts/DNS leak)"; exit 1
fi
echo "CANARY OK"
- name: Notify failure
if: failure()
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ -n "$CI_NOTIFY_WEBHOOK" ]; then
MSG="🚨 CI 金丝雀失败:runner->docker->网络->Gitea 链路异常,时间 $(date '+%Y-%m-%d %H:%M:%S'),请立即检查构建服务器"
python3 - "$CI_NOTIFY_WEBHOOK" "$MSG" <<'PY'
import json,sys,urllib.request
hook,msg=sys.argv[1],sys.argv[2]
data=json.dumps({"msg_type":"text","content":{"text":msg}}).encode()
urllib.request.urlopen(urllib.request.Request(hook,data=data,headers={"Content-Type":"application/json"}),timeout=10)
PY
fi
exit 0
+11 -60
View File
@@ -283,7 +283,6 @@ jobs:
sleep 5
done
- name: Run security checks
continue-on-error: true # Security scan is advisory; runner failure must not block deploy
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -334,7 +333,7 @@ jobs:
PIP_NO_CACHE_DIR: ''
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'false'
CI_USE_SHARED_PG: 'true'
permissions:
contents: read
steps:
@@ -518,7 +517,7 @@ jobs:
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'false'
CI_USE_SHARED_PG: 'true'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
@@ -1170,31 +1169,6 @@ jobs:
run: |
set +e
NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
- name: Render .env from template
shell: sh
env:
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
STAGING_REDIS_URL: ${{ secrets.STAGING_REDIS_URL }}
STAGING_CELERY_BROKER_URL: ${{ secrets.STAGING_CELERY_BROKER_URL }}
STAGING_CELERY_RESULT_BACKEND: ${{ secrets.STAGING_CELERY_RESULT_BACKEND }}
STAGING_JWT_SECRET_KEY: ${{ secrets.STAGING_JWT_SECRET_KEY }}
STAGING_MINIO_ENDPOINT: ${{ secrets.STAGING_MINIO_ENDPOINT }}
STAGING_MINIO_ACCESS_KEY: ${{ secrets.STAGING_MINIO_ACCESS_KEY }}
STAGING_MINIO_SECRET_KEY: ${{ secrets.STAGING_MINIO_SECRET_KEY }}
STAGING_MINIO_BUCKET: ${{ secrets.STAGING_MINIO_BUCKET }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }}
run: |
set -eu
echo "Rendering .env from template + secrets..."
bash scripts/render_env.sh staging
echo "✅ .env rendered (file contains secrets, not printed to log)"
# 验证文件存在且非空
test -s .env.rendered
echo "✅ .env.rendered validated ($(wc -l < .env.rendered) lines)"
- name: Docker login to Registry
shell: sh
env:
@@ -1267,31 +1241,9 @@ jobs:
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 配置 Diff 检查:下载服务器当前 .env,对比渲染结果,检测漂移
echo "Running config diff check..."
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/.env" .env.current 2>/dev/null \
|| touch .env.current # 首次部署时文件不存在,创建空文件
bash scripts/config_diff_check.sh .env.rendered .env.current
rm -f .env.current
echo "Config diff check done"
# 上传渲染后的 .env 到服务器(替代服务器上旧的 .env)
echo "Uploading rendered .env to staging server..."
# 备份旧 .env
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
"cp -f /var/lib/xiaoxia-saas-staging/.env /var/lib/xiaoxia-saas-staging/.env.bak.\$(date +%Y%m%d%H%M%S) 2>/dev/null || true"
# 上传新 .env
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no .env.rendered \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/.env"
echo "✅ .env uploaded to staging server"
# 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
# 清理 CI runner 上的渲染文件
rm -f .env.rendered
- name: Staging health check + auto rollback
if: success()
shell: sh
@@ -1462,7 +1414,7 @@ jobs:
- unit-tests
- frontend-lint
- frontend-unit-test
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
strategy:
fail-fast: false
matrix:
@@ -1546,7 +1498,11 @@ jobs:
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
TAG_NAME="${GITHUB_SHA}"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF_NAME}"
else
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
fi
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
@@ -1608,7 +1564,7 @@ jobs:
concurrency:
group: deploy-production-${{ gitea.ref }}
cancel-in-progress: false
if: github.event_name == 'push' && github.ref_name == 'main'
if: startsWith(github.ref, 'refs/tags/v')
needs:
- build-production
steps:
@@ -1681,7 +1637,7 @@ jobs:
echo "SSH connection verified"
# 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_production_deploy.sh | ssh -p "$production_port" -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
cat scripts/ci_production_deploy.sh | ssh -p "$production_port" -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "IMAGE_TAG=${GITHUB_REF_NAME} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
- name: Production health check + auto rollback
if: success()
@@ -1740,7 +1696,7 @@ jobs:
name: Production Browser E2E
runs-on: runtime-builder
timeout-minutes: 15
# if: removed - runs after deploy-production succeeds
if: startsWith(github.ref, 'refs/tags/v')
needs: deploy-production
steps:
- name: Checkout code
@@ -2060,11 +2016,6 @@ jobs:
echo " ⏳ $name: pending(审查中,暂不阻塞)"
continue
fi
# Security scan cancelled/failed时不阻塞部署(runner故障不应卡住流水线)
if [ "$name" = "validate-security" ] && { [ "$result" = "cancelled" ] || [ "$result" = "failure" ]; }; then
echo " ⚠️ $name: $result(安全扫描为非阻塞项,不卡住部署)"
continue
fi
check_job "$name" "$result"
done
@@ -1,59 +0,0 @@
name: Playwright Base Image Build
on:
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "构建 playwright 基础镜像"
jobs:
build-playwright:
name: Build Playwright Base Image
runs-on: runtime-builder
timeout-minutes: 30
steps:
- name: Docker login to Gitea Registry
shell: sh
env:
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login attempt $i/3 ==="
if printf '%s' "${GITEA_REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" --password-stdin; then
echo "Docker login successful"
break
fi
echo "Docker login failed (attempt $i/3), retrying in 5s..."
sleep 5
[ $i -eq 3 ] && exit 1
done
- name: Pull, retag and push Playwright image
shell: sh
run: |
set -eu
OFFICIAL_IMAGE="mcr.microsoft.com/playwright:v1.45.0-jammy"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy"
echo "=== Pulling official Playwright image ==="
docker pull "${OFFICIAL_IMAGE}"
echo "=== Tagging ==="
docker tag "${OFFICIAL_IMAGE}" "${GITEA_IMAGE}"
echo "=== Pushing to Gitea Registry ==="
docker push "${GITEA_IMAGE}"
echo "Done: ${GITEA_IMAGE}"
- name: Cleanup
if: always()
shell: sh
run: |
docker rmi "mcr.microsoft.com/playwright:v1.45.0-jammy" 2>/dev/null || true
docker rmi "git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy" 2>/dev/null || true
echo "Cleanup done"
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Auto Scan
# 作为短作业模式的兜底,防止事件驱动遗漏
on:
schedule:
# - cron: "*/15 * * * *" # DISABLED: temporarily to stop failure spam (2026-09-02) # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
- cron: "*/15 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
workflow_dispatch:
permissions:
+2 -3
View File
@@ -18,7 +18,7 @@ jobs:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 10 # 等待CI全绿+审批,需要充足时间
timeout-minutes: 3 # 等待模式:等CI全绿后自动合并,不遗漏任何PR
steps:
- name: Checkout code
shell: sh
@@ -61,8 +61,7 @@ jobs:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
needs: [auto-approve] # 修复竞态:必须等审批完成后再尝试合并
timeout-minutes: 15 # 等待审批+CI就绪+合并,需要充足时间
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
steps:
- name: Checkout code
shell: sh
-6
View File
@@ -24,11 +24,6 @@ ruff_cache/
.env.production
.env.staging
!.env.example
# 配置模板不受忽略规则限制
!deploy/configs/.env.staging
!deploy/configs/.env.production
# 渲染后的 env 文件包含真实密钥,绝不能提交
.env.rendered
# OS / editor
.DS_Store
@@ -59,4 +54,3 @@ frontend-v21-ui-prototype-final.html
!.vscode/settings.json
.vscode/extensions.json
.coverage
.env.current
@@ -1,26 +0,0 @@
"""add sort_order to template_categories
Revision ID: 061_sort_order
Revises: 060_migrate_segments
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
revision = "061_sort_order"
down_revision = "060_migrate_segments"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"template_categories",
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("template_categories", "sort_order")
@@ -1,28 +0,0 @@
"""re-add edit_plan_id to generation_tasks (align staging with production)
Revision ID: 062_edit_plan_id
Revises: 061_sort_order
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
revision = "062_edit_plan_id"
down_revision = "061_sort_order"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("edit_plan_id", sa.String(36), nullable=True),
)
op.create_index("ix_generation_tasks_edit_plan_id_2", "generation_tasks", ["edit_plan_id"])
def downgrade() -> None:
op.drop_index("ix_generation_tasks_edit_plan_id_2", table_name="generation_tasks")
op.drop_column("generation_tasks", "edit_plan_id")
@@ -1,46 +0,0 @@
"""add video_fingerprint_chunks table for per-chunk fingerprint storage
Revision ID: 063_fingerprint_chunks
Revises: 062_edit_plan_id
Create Date: 2026-09-03
"""
import sqlalchemy as sa
from alembic import op
revision = "063_fingerprint_chunks"
down_revision = "062_edit_plan_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"video_fingerprint_chunks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("video_id", sa.String(36), nullable=False),
sa.Column("project_id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False, server_default=""),
sa.Column("start_time_ms", sa.Integer, nullable=False),
sa.Column("end_time_ms", sa.Integer, nullable=False),
sa.Column("phash_binary", sa.String(16), nullable=False),
sa.Column("color_histogram", sa.JSON, nullable=False),
sa.Column("frame_count", sa.Integer, nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_vfc_video_id", "video_fingerprint_chunks", ["video_id"])
op.create_index("ix_vfc_project_id", "video_fingerprint_chunks", ["project_id"])
op.create_index("ix_vfc_user_id", "video_fingerprint_chunks", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_vfc_user_id", table_name="video_fingerprint_chunks")
op.drop_index("ix_vfc_project_id", table_name="video_fingerprint_chunks")
op.drop_index("ix_vfc_video_id", table_name="video_fingerprint_chunks")
op.drop_table("video_fingerprint_chunks")
@@ -1,25 +0,0 @@
"""add match_count and visual_similarity to generated_videos
Revision ID: 064_match_count_visual_sim
Revises: 063_fingerprint_chunks
Create Date: 2026-09-03
"""
import sqlalchemy as sa
from alembic import op
revision = "064_match_count_visual_sim"
down_revision = "063_fingerprint_chunks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("match_count", sa.Integer(), nullable=True, server_default="0"))
op.add_column("generated_videos", sa.Column("visual_similarity", sa.Float(), nullable=True, server_default="0.0"))
def downgrade() -> None:
op.drop_column("generated_videos", "visual_similarity")
op.drop_column("generated_videos", "match_count")
@@ -1,25 +0,0 @@
"""add visual_similarity and match_count to duplication_records
Revision ID: 065_dup_record_sim_match
Revises: 064_match_count_visual_sim
Create Date: 2026-09-04
"""
import sqlalchemy as sa
from alembic import op
revision = "065_dup_record_sim_match"
down_revision = "064_match_count_visual_sim"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("duplication_records", sa.Column("visual_similarity", sa.Float(), nullable=True))
op.add_column("duplication_records", sa.Column("match_count", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("duplication_records", "match_count")
op.drop_column("duplication_records", "visual_similarity")
+1 -1
View File
@@ -290,7 +290,7 @@ def list_assets(
else:
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页
paged_items = []
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
+1 -1
View File
@@ -456,7 +456,7 @@ async def wechat_callback(
user = user_repository.find_by_id(response.user_id)
binding_complete = False
if user:
binding_complete = bool(
binding_complete = (
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
-9
View File
@@ -7,7 +7,6 @@ from typing import Any
from uuid import uuid4
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.dependencies import get_duplication_repository
from app.schemas.duplication import (
@@ -77,8 +76,6 @@ def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
visual_similarity=getattr(record, "visual_similarity", None),
match_count=getattr(record, "match_count", None),
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
)
@@ -93,8 +90,6 @@ def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
visual_similarity=getattr(record, "visual_similarity", None),
match_count=getattr(record, "match_count", None),
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
segments=[
@@ -197,8 +192,6 @@ async def upload_for_duplication(
authenticated_user.user.id,
)
celery_app.send_task("worker.process_duplication_check", args=[record.id])
return DuplicationUploadResponse(
id=record.id,
status=record.status,
@@ -303,8 +296,6 @@ def retry_duplication(
detail=f"查重记录 {record_id} 不存在",
)
celery_app.send_task("worker.process_duplication_check", args=[updated.id])
return DuplicationUploadResponse(
id=updated.id,
status=updated.status,
+3 -3
View File
@@ -513,7 +513,7 @@ def generate_cover(
if generation_task_id:
try:
task = gen_task_repo.get(generation_task_id)
if task and getattr(task, "cover_url", ""): # type: ignore[arg-type]
if task and getattr(task, "cover_url", ""):
cover_url_from_task = task.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
@@ -538,7 +538,7 @@ def generate_cover(
gv_task_id = getattr(gv, "generation_task_id", "") or ""
if gv_task_id:
task_a2 = gen_task_repo.get(gv_task_id)
if task_a2 and getattr(task_a2, "cover_url", ""): # type: ignore[arg-type]
if task_a2 and getattr(task_a2, "cover_url", ""):
cover_url_from_task = task_a2.cover_url
logger.info(
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
@@ -747,7 +747,7 @@ def generate_cover(
if cover_url_from_task:
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
cover_data: dict[str, object] = { # type: ignore[no-redef]
cover_data = {
"type": "ai_frame",
"image_url": cover_url_from_task,
"frame_time": 0.0,
@@ -98,7 +98,7 @@ def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: s
try:
new_repo = SQLAlchemyEditTemplateRepository(db)
new_template = new_repo.get(template_id)
if new_template and getattr(new_template, "editing_mode", ""): # type: ignore[arg-type]
if new_template and getattr(new_template, "editing_mode", ""):
mode = new_template.editing_mode.strip()
if mode:
logger.info(
+5 -9
View File
@@ -92,9 +92,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
height=item.height,
fps=item.fps,
download_url=download_url,
duplicate_rate=getattr(item, "duplicate_rate", None),
visual_similarity=getattr(item, "visual_similarity", None),
match_count=getattr(item, "match_count", None),
)
@@ -140,6 +137,7 @@ def _select_assets_from_library(
return [a.id for a in ready_video_assets]
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
@@ -164,7 +162,7 @@ def _writeback_edit_plan_config(
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
@@ -176,12 +174,10 @@ def _writeback_edit_plan_config(
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
plan_id, old_title_text, new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
@@ -389,7 +385,7 @@ def create_generation_task(
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks: list = []
created_tasks = []
failed_tasks = []
user_id = authenticated_user.user.id
# 同批次任务共享 batch_id,用于视频查重时批次内比对
+109 -351
View File
@@ -23,10 +23,6 @@ import re
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session
# 默认转场时长(与 worker 端保持一致)
_DEFAULT_TRANSITION_DURATION = 0.5
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
@@ -47,14 +43,7 @@ from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
extract_scene_points_from_metadata,
pick_scene_aware_start,
pick_start_in_scene_segment,
)
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.domain.plan_generator_utils import _calc_random_start_time
from packages.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services
@@ -481,12 +470,6 @@ def _recommended_time_conflicts(
return False
# 向后兼容别名:镜头段构建/段内取点逻辑已下沉到 packages.domain.plan_generator_utils
# 旧测试与历史代码仍按 clips._build_scene_segments / _pick_start_in_scene_segment 导入
_build_scene_segments = build_scene_segments
_pick_start_in_scene_segment = pick_start_in_scene_segment
def _get_mediakit_recommendations(
asset_ids: list[str],
asset_repo,
@@ -599,47 +582,6 @@ def _get_mediakit_recommendations(
return {}
def _calc_plan_internal_duplicate_rate(clips_data: list[dict]) -> float:
"""估算单条成片内部重复率(%.
检查本条成片中同一素材是否有重叠的时间区间。
重叠时长 / 成片总时长 * 100 = 内部重复率。
这是一个轻量估算,不依赖视频指纹;完整查重由 worker 异步完成。
"""
if not clips_data:
return 0.0
# 按素材分组
by_asset: dict[str, list[tuple[float, float]]] = {}
total_duration = 0.0
for c in clips_data:
aid = c.get("asset_id", "")
if not aid:
continue
start = c.get("start_time", 0.0)
end = start + c.get("duration", 0.0)
by_asset.setdefault(aid, []).append((start, end))
total_duration += c.get("duration", 0.0)
if total_duration <= 0:
return 0.0
# 检查同素材内的区间重叠
overlap_duration = 0.0
for segments in by_asset.values():
if len(segments) < 2:
continue
segments_sorted = sorted(segments, key=lambda s: s[0])
last_end = segments_sorted[0][1]
for start, end in segments_sorted[1:]:
overlap = max(0.0, min(end, last_end) - start)
if overlap > 0:
overlap_duration += overlap
last_end = max(last_end, end)
return round(overlap_duration / total_duration * 100, 1)
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor(
template_id: str,
@@ -684,26 +626,10 @@ def create_clips_from_assets_editor(
# 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {}
asset_smart_scores: dict[str, float] = {}
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
asset_scene_points: dict[str, list[float]] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
asset_scene_points[asset_id] = cached_points
logger.info(
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
len(asset_scene_points),
len(unique_asset_ids),
)
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
@@ -736,52 +662,19 @@ def create_clips_from_assets_editor(
return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
# 素材耗尽标志:某轮循环中所有素材均被跳过时为 True
all_assets_exhausted = False
# 计算转场重叠补偿:每个 clip 需要额外增加的时长
# 目标:渲染后视频总时长 = 模板设定的各片段时长之和
# 公式:每 clip 增加 (n_segments - 1) * td / n_segments
n_segments = len(segments)
if n_segments > 1:
transition_compensation = (n_segments - 1) * _DEFAULT_TRANSITION_DURATION / n_segments
else:
transition_compensation = 0.0
# 打乱 segments 的处理顺序(分配素材的顺序随机化),但最终 clips_data 按原始 order 排序
shuffled_indices = list(range(len(segments)))
random.shuffle(shuffled_indices)
for idx in shuffled_indices:
_seg_order, dur_min, dur_max = segments[idx]
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
raw_duration += transition_compensation
# 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先),
# 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。
# 跳过时长缺失、复用占比已超 10% 阈值的素材;
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续尝试下一个素材
# 继续轮询下一个素材
asset_id = ""
clip_duration = 0.0
start_time: float | None = None
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in asset_ids}
# 排序键:smart_match 评分(注入随机噪声)→ 使用次数 → 纯随机。
# 噪声让得分接近的素材排名每次浮动,避免同一批素材反复选出相同组合,
# 从素材组合层面降低成片查重率;分差 > SCORE_RANDOM_NOISE_MAX 时排名稳定,
# 质量差距显著的素材仍保持优先级。
sorted_candidates = sorted(
asset_ids,
key=lambda aid: (
-(asset_smart_scores.get(aid, 0.0) + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX)),
asset_use_counts.get(aid, 0),
random.random(),
),
)
for candidate in sorted_candidates:
n_assets = len(asset_ids)
for offset in range(n_assets):
candidate = asset_ids[(i + offset) % n_assets]
candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0:
continue
@@ -795,30 +688,16 @@ def create_clips_from_assets_editor(
candidate,
)
continue
# 起始时间选取(不调用 MediaKit,保证接口快速返回)
# 1) 素材有场景切换点缓存时,优先从随机镜头段中选起点(不同片段来自不同镜头,
# 画面内容本质不同),与 used_segments 做冲突避让(含 1.5s 边缘间隙
# 2) 无缓存 / 镜头段全冲突 → _calc_random_start_time 随机起点兜底;
# 100 次避不开历史区间时走受控复用回调(复用片段累加 reused_durations
# 回调内部预判复用后占比超 10% 则拒绝并返回 None)
candidate_start = None
if candidate in asset_scene_points:
candidate_start = pick_scene_aware_start(
candidate,
candidate_duration,
asset_durations,
asset_scene_points,
used_segments,
edge_gap=SEGMENT_EDGE_GAP,
)
if candidate_start is None:
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
# 随机起始时间(不调用 MediaKit,保证接口快速返回)100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 15% 则拒绝并返回 None
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
if candidate_start is None:
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材
logger.info(
@@ -833,7 +712,6 @@ def create_clips_from_assets_editor(
if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
all_assets_exhausted = True
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材可切区间不足,请补充新素材",
@@ -849,7 +727,7 @@ def create_clips_from_assets_editor(
clips_data.append(
{
"order": _seg_order,
"order": i,
"asset_id": asset_id,
"start_time": start_time,
"duration": clip_duration,
@@ -857,9 +735,6 @@ def create_clips_from_assets_editor(
}
)
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...
clips_data.sort(key=lambda c: c["order"])
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
@@ -879,31 +754,11 @@ def create_clips_from_assets_editor(
unique_asset_ids,
)
# 6. 估算成片内部重复率(本条成片中同一素材的重叠片段时长占比)
dup_rate = _calc_plan_internal_duplicate_rate(clips_data)
duplicate_warning = None
if dup_rate > 50:
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
logger.warning(
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
plan_id,
dup_rate,
)
# 7. 素材耗尽提示
exhaustion_warning = None
if all_assets_exhausted and created_count < len(segments):
exhaustion_warning = (
"素材可切区间不足,部分片段使用了复用素材。" "建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
)
# 8. 立即返回响应
# 6. 立即返回响应
return ClipsFromAssetsResponse(
created_count=created_count,
plan_id=plan_id,
clip_ids=[],
duplicate_warning=duplicate_warning,
exhaustion_warning=exhaustion_warning,
)
@@ -911,15 +766,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str,
asset_ids: list[str],
) -> None:
"""后台任务:使SceneChange 智能选并更新片段的起始时间.
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
降级策略:
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
"""后台任务:MediaKit 智能选并更新片段的起始时间.
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
@@ -941,6 +788,12 @@ def _update_mediakit_recommendations_async( # pragma: no cover
asset_repo = SQLAlchemyAssetRepository(db)
plan_svc = EditPlanService(db)
# 调用 MediaKit 获取推荐时间
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
if not recommendations:
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
return
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
batch_size = 500
all_clips = []
@@ -963,16 +816,15 @@ def _update_mediakit_recommendations_async( # pragma: no cover
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段
clips_by_asset: dict[str, list] = defaultdict(list)
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
for clip in clips:
aid = getattr(clip, "asset_id", "") or ""
if aid:
clips_by_asset[aid].append(clip)
for aid in clips_by_asset:
clips_by_asset[aid].sort(key=lambda c: c.order)
if aid and clip.start_time is not None:
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
@@ -981,22 +833,16 @@ def _update_mediakit_recommendations_async( # pragma: no cover
updated_segments: dict[str, list[tuple[float, float]]] = {}
updated_count = 0
# 尝试获取存储服务(用于生成视频 URL)
try:
storage = get_storage_service()
except Exception:
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
return
# 获取 MediaKit 客户端
client = get_mediakit_client()
# 对每个素材,检测场景切换点并分配镜头段
for asset_id in unique_asset_ids:
asset_clips = clips_by_asset.get(asset_id, [])
if not asset_clips:
# 遍历片段,按 asset_id 匹配推荐时间
for clip in clips:
asset_id = getattr(clip, "asset_id", "") or ""
if not asset_id or asset_id not in recommendations:
continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id)
if not asset:
continue
@@ -1004,177 +850,89 @@ def _update_mediakit_recommendations_async( # pragma: no cover
if asset_total <= 0:
continue
# 获取素材视频 URL
video_url: str | None = None
storage_key = getattr(asset, "storage_key", None) or ""
mime = getattr(asset, "mime_type", "") or ""
if storage_key and mime.startswith("video/"):
try:
video_url = storage.get_download_url(storage_key)
except Exception as e:
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
# 构建该素材的占用区间列表(排除已更新片段)
def _get_other_segments(asset_id_inner, clip_id_inner):
segs: list[tuple[float, float]] = []
for c in clips_by_asset.get(asset_id_inner, []):
cid = c.id
if cid != clip_id_inner and cid not in updated_clip_ids:
segs.append((c.start_time, c.start_time + c.duration))
segs.extend(updated_segments.get(asset_id_inner, []))
# 并入历史已用区间
def _norm(segs_in):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
# 优先使用 SceneChange 策略
scene_segments: list[tuple[float, float]] = []
# 先查素材 metadata 中的场景点缓存:命中则直接复用,跳过 MediaKit 检测
# (缓存由本任务首次检测后写入,跨任务/跨 plan 复用)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
scene_segments = build_scene_segments(cached_points, asset_total)
# 推荐时间 + 片段时长不能超过素材总时长
if recommended_start + clip_duration > asset_total:
logger.info(
"后台任务: 命中场景点缓存: asset_id=%s scenes=%d",
asset_id,
len(scene_segments),
)
if not scene_segments and client.is_available and video_url:
scene_changes = client.detect_scene_changes(video_url)
if scene_changes is not None:
scene_segments = build_scene_segments(scene_changes, asset_total)
logger.info(
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
asset_id,
len(scene_segments),
)
# 检测结果写入素材 metadata 缓存:首次生成用随机起点,
# 检测完成后后续生成的渲染前同步路径即可读缓存选镜头段
try:
existing_meta = dict(getattr(asset, "metadata", None) or {})
existing_meta["scene_change_points"] = scene_changes
asset.metadata = existing_meta # type: ignore[attr-defined]
asset_repo.update(asset) # type: ignore[arg-type]
logger.info(
"后台任务: 场景点已写入素材缓存: asset_id=%s points=%d",
asset_id,
len(scene_changes),
)
except Exception as cache_err:
# 缓存写入失败不影响本次片段更新
logger.warning(
"后台任务: 场景点缓存写入失败: asset_id=%s error=%s",
asset_id,
cache_err,
)
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
if not scene_segments and video_url:
fallback_recs = _get_mediakit_recommendations([asset_id], asset_repo)
if fallback_recs and asset_id in fallback_recs:
# analyze_videos 只返回单个推荐点,转为单镜头段
rec_start = fallback_recs[asset_id]
scene_segments = [(rec_start, asset_total)]
logger.info(
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
asset_id,
rec_start,
)
if not scene_segments:
# 所有方式都失败 → 保持现有随机 start_time
logger.info(
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
asset_id,
recommended_start,
clip_duration,
asset_total,
)
continue
# 为每个片段分配不同的镜头段
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
for clip in asset_clips:
clip_duration = clip.duration
recommended_start: float | None = None
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
other_segments: list[tuple[float, float]] = [
(cs, ce)
for cid, cs, ce in clips_by_asset.get(asset_id, [])
if cid != clip.id and cid not in updated_clip_ids
]
other_segments.extend(updated_segments.get(asset_id, []))
# 从镜头段池中依次尝试,选一个不冲突的
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool):
candidate_start = pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
if candidate_start is None:
continue # 镜头段太短,跳过
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
# 本 plan 片段创建时已写入历史记录
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
def _norm(segs):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
# 检查越界
if candidate_start + clip_duration > asset_total:
continue
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
# 检查与已用区间冲突
other_segs = _get_other_segments(asset_id, clip.id)
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
continue
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
logger.info(
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
continue
recommended_start = candidate_start
# 消费该镜头段(从池中移除,下一个片段用不同镜头段)
scene_segments_pool.pop(seg_idx)
break
if recommended_start is None:
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底
used_segs_for_calc: dict[str, list[tuple[float, float]]] = {
asset_id: _get_other_segments(asset_id, clip.id)
}
fallback_start = _calc_random_start_time(
asset_id,
clip_duration,
{asset_id: asset_total},
used_segs_for_calc,
)
if fallback_start is None:
continue # 完全无法分配,保持原起点
recommended_start = fallback_start
# 更新片段起始时间
# 逐个更新并捕获异常(单点失败不影响其他片段)
try:
old_start = clip.start_time
old_end = old_start + clip_duration
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
# 写新区间,最后统一 commit;任一步失败整体 rollback
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
plan_svc.update_clip(clip.id, start_time=recommended_start)
try:
old_start = clip.start_time
old_end = old_start + clip_duration
plan_svc.update_clip(clip.id, start_time=recommended_start)
try:
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
updated_segments.setdefault(asset_id, []).append(
(recommended_start, recommended_start + clip_duration)
)
logger.info(
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
asset_id,
recommended_start,
me,
)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
@@ -41,17 +41,17 @@ def list_editor_transition_presets(
_: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse:
"""获取转场预设列表"""
from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY
from packages.domain.transition_presets import TRANSITION_PRESETS
items = [
{
"id": p.id,
"name": p.name,
"category": p.category,
"duration": p.default_duration,
"description": p.description,
"id": p["id"],
"name": p["name"],
"category": p.get("category", "通用"),
"duration": p.get("default_duration", 0.5),
"description": p.get("description", ""),
}
for p in TRANSITION_PRESET_LIBRARY
for p in TRANSITION_PRESETS
]
return TransitionPresetListResponse(items=items, total=len(items))
@@ -123,17 +123,17 @@ def list_editor_filter_presets(
_: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY
from packages.domain.filter_presets import FILTER_PRESETS
items = [
{
"id": p.id,
"name": p.name,
"category": p.category,
"thumbnail": p.lut_url,
"description": p.description,
"id": p["id"],
"name": p["name"],
"category": p.get("category", "通用"),
"thumbnail": p.get("thumbnail", ""),
"description": p.get("description", ""),
}
for p in FILTER_PRESET_LIBRARY
for p in FILTER_PRESETS
]
return FilterPresetListResponse(items=items, total=len(items))
@@ -189,8 +189,6 @@ class ClipsFromAssetsResponse(BaseModel):
plan_id: str = ""
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
# ── 封面配置 ────────────────────────────────────────────────────────────────
+1 -22
View File
@@ -2,9 +2,7 @@
from __future__ import annotations
import json
import logging
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Optional
@@ -432,8 +430,6 @@ def save_tts_job_to_library(
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
tmp_path: Path | None = None
audio_duration: float | None = None
file_size = 0
try:
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
tmp_path = Path(tmp.name)
@@ -449,23 +445,6 @@ def save_tts_job_to_library(
)
file_size = tmp_path.stat().st_size
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
# 从音频文件提取时长(ffprobe),作为 job.duration 的兜底
try:
proc = subprocess.run(
[
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", str(tmp_path),
],
capture_output=True, text=True, timeout=10,
)
if proc.returncode == 0:
fmt = json.loads(proc.stdout).get("format", {})
dur = float(fmt.get("duration", 0))
if dur > 0:
audio_duration = dur
except Exception:
logger.warning("ffprobe 提取时长失败: job_id=%s", job.id, exc_info=True)
except HTTPException:
raise
except Exception as e:
@@ -503,7 +482,7 @@ def save_tts_job_to_library(
mime_type=content_type,
metadata=metadata_,
file_size=file_size,
duration=job.duration or audio_duration or None,
duration=job.duration or None,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
uploaded_by_user_id=user_id,
+1 -68
View File
@@ -23,7 +23,6 @@ from app.schemas.upload import (
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.domain import Asset, AssetStatus
logger = logging.getLogger(__name__)
@@ -81,40 +80,6 @@ def _validate_mime_type(content_type: str | None) -> str:
return base_type
def _infer_mime_type_from_storage_key(storage_key: str) -> str:
"""从 storage_key 推断 MIME 类型(与 worker 端保持一致)。"""
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
_MIME_MAP = {
".mov": "video/quicktime", ".mp4": "video/mp4", ".avi": "video/x-msvideo",
".mkv": "video/x-matroska", ".webm": "video/webm",
".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp",
".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
".flac": "audio/flac", ".m4a": "audio/x-m4a",
}
for ext, mime in _MIME_MAP.items():
if lower_filename.endswith(ext):
return mime
return "video/mp4" # default
def _create_pending_asset(
asset_repository, project_id, library_id, storage_key, filename, mime_type, user_id, file_hash=""
):
"""立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。"""
asset = Asset.create(
project_id=project_id,
library_id=library_id,
name=filename,
storage_key=storage_key,
mime_type=mime_type,
status=AssetStatus.PROCESSING,
uploaded_by_user_id=user_id,
file_hash=file_hash,
)
return asset_repository.create(asset)
def _submit_ingest_job(
project_id: str,
library_id: str,
@@ -244,20 +209,6 @@ async def complete_direct_upload(
url=storage_service.get_url(normalized_key),
)
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
filename = normalized_key.rsplit("/", 1)[-1]
mime_type = _infer_mime_type_from_storage_key(normalized_key)
pending_asset = _create_pending_asset(
asset_repository=asset_repository,
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
filename=filename,
mime_type=mime_type,
user_id=authenticated_user.user.id,
file_hash=request.file_hash,
)
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
@@ -265,12 +216,7 @@ async def complete_direct_upload(
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id=job.id,
asset_id=pending_asset.id,
url=storage_service.get_url(normalized_key),
)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key))
@router.post(
@@ -338,18 +284,6 @@ async def upload_asset(
detail=f"Failed to upload file: {type(error).__name__}",
) from error
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
pending_asset = _create_pending_asset(
asset_repository=asset_repository,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
filename=safe_filename,
mime_type=validated_content_type,
user_id=authenticated_user.user.id,
file_hash=file_hash,
)
job = _submit_ingest_job(
project_id=project_id,
library_id=library_id,
@@ -361,6 +295,5 @@ async def upload_asset(
return UploadAssetResponse(
storage_key=storage_key,
ingest_job_id=job.id,
asset_id=pending_asset.id,
url=file_url,
)
-2
View File
@@ -53,8 +53,6 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
duplicate_rate=getattr(item, "duplicate_rate", None),
visual_similarity=getattr(item, "visual_similarity", None),
match_count=getattr(item, "match_count", None),
)
+2 -260
View File
@@ -6,26 +6,12 @@
from __future__ import annotations
import logging
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Literal, Optional
from uuid import uuid4
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_audio_url_signer,
get_cosyvoice_service,
get_db_session,
get_project_repository,
get_user_repository,
)
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
@@ -38,7 +24,7 @@ from app.schemas.voice_library import (
UpdateVoiceLibraryRequest,
VoiceLibraryItemResponse,
)
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
@@ -54,12 +40,8 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError,
UpdateVoiceLibraryUseCase,
)
from packages.domain import Asset, AssetStatus
from packages.domain.classification import AssetLibraryKind, ClassificationStatus
from packages.domain.entities import AssetLibrary
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
from packages.ports.user_repository import UserRepository
from packages.shared.storage import SharedStorageService
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -525,243 +507,3 @@ def delete_voice(
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return
# ── 提取视频配音 ─────────────────────────────────────────────────────
# 支持的视频格式
EXTRACT_VIDEO_MIMES = frozenset({"video/mp4", "video/quicktime", "video/webm", "video/x-msvideo"})
MAX_EXTRACT_SIZE = 500 * 1024 * 1024 # 500MB
@router.post(
"/extract-voice",
status_code=status.HTTP_201_CREATED,
)
def extract_voice_from_video(
file: UploadFile = File(...),
project_id: str = Form(...),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_library_repository=Depends(get_asset_library_repository),
asset_repository=Depends(get_asset_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
sign_url=Depends(get_audio_url_signer),
):
"""从上传的视频中提取人声配音。
流程:
1. 接收视频文件(mp4/mov/webm
2. ffmpeg 提取音频 + 降噪 + 编码为 mp3
3. 上传到 OSS,创建 Asset 记录到配音素材库
4. 返回素材信息(时长、文件大小、URL)
"""
user_id = authenticated_user.user.id
# 校验文件类型
content_type = file.content_type or ""
if content_type and content_type not in EXTRACT_VIDEO_MIMES:
# 兜底:按扩展名判断
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
ext_to_mime = {"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "avi": "video/x-msvideo"}
if ext not in ext_to_mime:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持 mp4/mov/webm/avi 格式的视频文件",
)
content_type = ext_to_mime[ext]
# 找到(或自动创建)用户 voice 素材库(复用 TTS 的逻辑)
library = _find_or_create_voice_library_for_extract(
user_id=user_id,
project_repository=project_repository,
asset_library_repository=asset_library_repository,
)
tmp_dir = None
try:
tmp_dir = Path(tempfile.mkdtemp(prefix="voice_extract_"))
video_path = tmp_dir / f"input_{uuid4().hex[:8]}_{file.filename or 'video.mp4'}"
audio_path = tmp_dir / f"output_{uuid4().hex[:8]}.mp3"
# 保存上传的视频到临时文件
with open(video_path, "wb") as f:
total = 0
while chunk := file.file.read(1024 * 1024): # 1MB chunks
total += len(chunk)
if total > MAX_EXTRACT_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="视频文件过大,最大支持 500MB",
)
f.write(chunk)
if video_path.stat().st_size == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="视频文件为空")
# ffmpeg: 提取音频 + 降噪 + 编码 mp3
# 滤镜链:highpass(去低频噪声) → afftdn(FFT降噪) → lowpass(去高频噪声)
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-i",
str(video_path),
"-vn", # 不要视频
"-af",
"highpass=f=80,afftdn=nf=-25:tn=1,lowpass=f=8000",
"-acodec",
"libmp3lame",
"-ab",
"192k",
"-ar",
"44100",
"-ac",
"1", # 单声道(人声足够)
str(audio_path),
]
result = subprocess.run(
ffmpeg_cmd,
capture_output=True,
timeout=300, # 5 分钟超时
)
if result.returncode != 0:
stderr_text = result.stderr.decode("utf-8", errors="replace")[-500:]
logger.error("ffmpeg 提取配音失败: %s", stderr_text)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="视频音频提取失败,可能该视频没有音轨或格式不支持",
)
if not audio_path.exists() or audio_path.stat().st_size == 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="音频提取结果为空",
)
# 获取音频时长
duration = _get_audio_duration(audio_path)
file_size = audio_path.stat().st_size
# 上传到 OSS
audio_ext = "mp3"
storage_key = f"uploads/voice/extracted/{uuid4().hex}.{audio_ext}"
storage_service.upload_file(audio_path, storage_key, content_type="audio/mpeg")
# 创建 Asset 记录
original_name = (file.filename or "video").rsplit(".", 1)[0]
asset_name = f"{original_name}-配音"
asset = Asset.create(
project_id=library.project_id,
library_id=library.id,
name=asset_name,
storage_key=storage_key,
mime_type="audio/mpeg",
metadata={
"source": "video_extract",
"original_video": file.filename or "unknown",
},
file_size=file_size,
duration=duration,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING,
uploaded_by_user_id=user_id,
)
asset = asset_repository.create(asset)
return {
"id": asset.id,
"name": asset.name,
"audio_url": sign_url(storage_key),
"duration": duration,
"file_size": file_size,
"status": "completed",
"source": "video_extract",
}
except HTTPException:
raise
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="视频处理超时,请尝试较短的视频",
) from None
except Exception as e:
logger.exception("提取视频配音失败: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="提取配音失败,请稍后重试",
) from e
finally:
# 清理临时文件
if tmp_dir and Path(tmp_dir).exists():
shutil.rmtree(tmp_dir, ignore_errors=True)
def _find_or_create_voice_library_for_extract(*, user_id, project_repository, asset_library_repository):
"""为用户找到或创建 voice 素材库(与 TTS 保存逻辑一致)。"""
projects = project_repository.find_accessible_projects(user_id)
if not projects:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有可用的项目,请先创建项目",
)
for project in projects:
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
# 自动创建
from sqlalchemy.exc import IntegrityError
project = projects[0]
library = AssetLibrary.create(
project_id=project.id,
name="配音素材库",
kind=AssetLibraryKind.VOICE,
)
try:
return asset_library_repository.create(library)
except IntegrityError:
session = getattr(asset_library_repository, "session", None)
if session is not None:
try:
session.rollback()
except Exception:
pass
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="配音素材库创建失败",
) from None
def _get_audio_duration(audio_path: Path) -> float:
"""用 ffprobe 获取音频时长(秒)。"""
try:
result = subprocess.run(
[
"ffprobe",
"-v",
"quiet",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
str(audio_path),
],
capture_output=True,
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return float(result.stdout.strip())
except (ValueError, subprocess.TimeoutExpired):
pass
return 0.0
-3
View File
@@ -28,9 +28,6 @@ class DuplicationRecordResponse(BaseModel):
status: str = "pending"
duplicate_rate: float | None = None
duplicate_count: int = 0
# #1661 视觉相似度(归一化 0~1)/ 匹配视频数
visual_similarity: float | None = None
match_count: int | None = None
created_at: str
updated_at: str
-4
View File
@@ -25,10 +25,6 @@ class GeneratedVideoResponse(BaseModel):
review_status: str = "pending_review"
generation_params: dict = Field(default_factory=dict)
download_url: str | None = None
# #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数
duplicate_rate: float | None = None
visual_similarity: float | None = None
match_count: int | None = None
class GeneratedVideoDownloadUrlResponse(BaseModel):
-3
View File
@@ -22,10 +22,7 @@ class VideoItemResponse(BaseModel):
generation_params: dict = Field(default_factory=dict)
download_url: str | None = None
generated_at: str = ""
# #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数
duplicate_rate: float | None = None
visual_similarity: float | None = None
match_count: int | None = None
class ListVideosResponse(BaseModel):
@@ -3,7 +3,7 @@
在素材 metadataassets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 10%),把任意两条
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``::
@@ -40,14 +40,14 @@ logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 2
MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.10
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(10%)。
REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 1.5
SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
@@ -397,12 +397,12 @@ def make_reuse_callback(
db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(10% 阈值)。
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 10%
ratio_limit: 单条成片复用时长占比上限,默认 15%
Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
@@ -13,7 +13,6 @@
from __future__ import annotations
import logging
import random
from typing import Any, List
from sqlalchemy.orm import Session
@@ -30,11 +29,9 @@ from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import (
create_clips_from_configs,
distribute_assets,
extract_scene_points_from_metadata,
generate_default_clips,
map_clip_types_for_mode,
)
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.domain.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__)
@@ -131,7 +128,6 @@ class PlanGeneratorService:
editing_mode,
random_selection=random_preview,
asset_durations=asset_durations,
user_id=created_by_user_id,
)
# 5. 持久化所有 clips 并计算总时长
@@ -219,82 +215,19 @@ class PlanGeneratorService:
*,
random_selection: bool = False,
asset_durations: dict[str, float] | None = None,
user_id: str = "",
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
先用 smart_match 评分对素材排序(高分优先),再委托给
plan_generator_utils.distribute_assets 纯函数完成分配。
委托给 plan_generator_utils.distribute_assets 纯函数。
"""
# 预览随机模式:素材顺序已 shuffle,纯随机起点即可,不读 DB 评分/缓存
asset_scene_points: dict[str, list[float]] = {}
if not random_selection:
# 正式生成:smart_match 评分排序(高分优先)+ 场景切换点缓存
if self._asset_repo:
asset_ids = self._sort_assets_by_smart_score(asset_ids)
# 读取素材 metadata 中的场景切换点缓存(后台 SceneChange 检测写入):
# 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底
asset_scene_points = self._fetch_asset_scene_points(asset_ids)
# 正式生成也随机重排片段顺序(降重,默认开启无开关)
# smart_match 决定选哪些素材,shuffle 只改变分配到 clips 的顺序
asset_ids = list(asset_ids) # 复制避免修改调用方原列表
random.shuffle(asset_ids)
# 查询已有视频的已用区间(跨视频避让)
external_used_segments = None
if user_id and self._clip_repo:
try:
external_used_segments = self._clip_repo.list_used_segments_by_user(user_id, limit_recent=50)
except Exception:
logger.warning("跨视频避让查询失败,回退到纯随机", exc_info=True)
distribute_assets(
clips,
asset_ids,
editing_mode,
random_selection=random_selection,
asset_durations=asset_durations,
asset_scene_points=asset_scene_points,
external_used_segments=external_used_segments,
)
def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]:
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
points_map: dict[str, list[float]] = {}
if not self._asset_repo:
return points_map
for asset_id in asset_ids:
asset = self._asset_repo.get(asset_id)
if asset:
points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if points:
points_map[asset_id] = points
return points_map
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
"""
scored: list[tuple[str, float]] = []
for asset_id in asset_ids:
asset = self._asset_repo.get(asset_id)
if asset:
score, _ = score_asset(asset)
scored.append((asset_id, score))
else:
scored.append((asset_id, 0.0))
# 评分 + 随机噪声后按降序排列
scored.sort(
key=lambda x: x[1] + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX),
reverse=True,
)
return [aid for aid, _ in scored]
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
"""从数据库获取素材时长信息.
@@ -1,174 +0,0 @@
#!/usr/bin/env python3
"""存量指纹重建脚本 — 为已有视频生成 video_fingerprint_chunks 分片数据。
功能:
- 查询 generated_videos 中 video_fingerprint IS NOT NULL 但尚无分片数据的视频
- 从 OSS 下载视频 → 用新的分片算法重新计算指纹 → 写入分片表
- 支持 --dry-run(只打印不写入)和 --batch-size(默认 50
- 幂等:已存在分片数据的视频跳过
用法:
# 预览(不写入)
python rebuild_fingerprint_chunks.py --dry-run
# 执行重建
python rebuild_fingerprint_chunks.py --batch-size 50
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import tempfile
# 确保可以 import worker_app 和 packages
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "worker"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("rebuild_fingerprint_chunks")
def find_videos_needing_rebuild(session, batch_size: int) -> list[dict]:
"""查询需要重建分片指纹的视频。"""
from sqlalchemy import and_
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel, VideoFingerprintChunkModel
# 有 video_fingerprint 的视频
has_fingerprint = GeneratedVideoModel.video_fingerprint.isnot(None)
has_fingerprint = and_(has_fingerprint, GeneratedVideoModel.video_fingerprint != "")
# 排除已有分片数据的视频
subq = session.query(VideoFingerprintChunkModel.video_id).distinct().subquery()
no_chunks = ~GeneratedVideoModel.id.in_(subq)
videos = (
session.query(GeneratedVideoModel)
.filter(and_(has_fingerprint, no_chunks))
.order_by(GeneratedVideoModel.generated_at.desc())
.limit(batch_size)
.all()
)
return [
{
"id": v.id,
"project_id": v.project_id,
"user_id": v.user_id or "",
"duration": v.duration,
}
for v in videos
]
def rebuild_one(video_info: dict, dry_run: bool = False) -> int:
"""重建单个视频的分片数据。返回写入的 chunk 数量。"""
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
from packages.shared.storage import get_storage_service
video_id = video_info["id"]
project_id = video_info["project_id"]
user_id = video_info["user_id"]
if dry_run:
logger.info("[DRY-RUN] Would rebuild video %s (project=%s)", video_id, project_id)
return 0
session = SessionLocal()
temp_dir = tempfile.mkdtemp()
try:
# 再次检查幂等性
existing_count = (
session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count()
)
if existing_count > 0:
logger.info("Video %s already has %d chunks, skipping", video_id, existing_count)
return 0
# 下载视频
storage_service = get_storage_service()
local_path = os.path.join(temp_dir, f"{video_id}.mp4")
storage_key = f"projects/{project_id}/generated/{video_id}/{video_id}.mp4"
storage_service.download_file(storage_key, local_path)
# 重新计算指纹
deduplicator = VideoDeduplicator()
fingerprint = deduplicator.compute_fingerprint(local_path)
# 写入分片表
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
session.commit()
chunk_count = len(fingerprint.chunks)
logger.info("Rebuilt %d chunks for video %s", chunk_count, video_id)
return chunk_count
except Exception as e:
logger.error("Failed to rebuild video %s: %s", video_id, e)
session.rollback()
return -1
finally:
session.close()
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
def main():
parser = argparse.ArgumentParser(description="存量指纹重建脚本")
parser.add_argument("--dry-run", action="store_true", help="只打印不写入")
parser.add_argument("--batch-size", type=int, default=50, help="每批处理数量(默认 50")
parser.add_argument("--total-limit", type=int, default=0, help="总处理数量限制(0=不限制)")
args = parser.parse_args()
from worker_app.db import SessionLocal
session = SessionLocal()
try:
videos = find_videos_needing_rebuild(session, args.batch_size)
logger.info("Found %d videos needing rebuild", len(videos))
if args.dry_run:
for v in videos:
logger.info("[DRY-RUN] Video %s | project=%s | duration=%.1fs", v["id"], v["project_id"], v["duration"])
return
total_chunks = 0
processed = 0
failed = 0
for v in videos:
if args.total_limit > 0 and processed >= args.total_limit:
break
result = rebuild_one(v, dry_run=False)
if result < 0:
failed += 1
else:
total_chunks += result
processed += 1
logger.info(
"Rebuild complete: processed=%d, chunks=%d, failed=%d",
processed,
total_chunks,
failed,
)
finally:
session.close()
if __name__ == "__main__":
main()
+14 -7
View File
@@ -1848,9 +1848,10 @@
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -1937,9 +1938,10 @@
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
@@ -3111,9 +3113,10 @@
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
@@ -4454,9 +4457,10 @@
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
@@ -5004,9 +5008,10 @@
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
@@ -5019,9 +5024,10 @@
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
@@ -5729,9 +5735,10 @@
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
-4
View File
@@ -20,10 +20,6 @@ export interface DuplicationRecord {
duplicate_rate?: number
/** 重复片段数 */
duplicate_count?: number
/** 视觉相似度(0-100),#1660 新增 */
visual_similarity?: number
/** 匹配帧数,#1660 新增 */
match_count?: number
/** 创建时间 */
created_at: string
/** 更新时间 */
-9
View File
@@ -23,10 +23,6 @@ export interface ProductItem {
project_name?: string
/** 查重率(百分比) */
duplicate_rate?: number
/** 视觉相似度(0-100),#1660 新增 */
visual_similarity?: number
/** 匹配帧数,#1660 新增 */
match_count?: number
created_at?: string
updated_at?: string
}
@@ -75,9 +71,4 @@ export interface VideoItem {
generation_params: Record<string, unknown>
download_url: string
generated_at: string
duplicate_rate?: number
/** 视觉相似度(0-100),#1660 新增 */
visual_similarity?: number
/** 匹配帧数,#1660 新增 */
match_count?: number
}
+2 -3
View File
@@ -29,8 +29,7 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
duplicate_rate: video.duplicate_rate,
visual_similarity: video.visual_similarity,
match_count: video.match_count,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
-1
View File
@@ -28,5 +28,4 @@ export {
deleteTTSJob,
getTtsVoices,
previewTts,
extractVideoVoice,
} from "./jobs"
-53
View File
@@ -70,56 +70,3 @@ export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewRes
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
/**
* 从视频中提取配音(上传视频 → 后端提取人声 → 保存到配音素材库)
* 支持 mp4/mov/webm 格式
*/
export const extractVideoVoice = async (
file: File,
onProgress?: (percent: number) => void,
): Promise<{ asset_id: string; duration: number }> => {
const formData = new FormData()
formData.append("file", file)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", "/api/v1/voices/extract-voice")
// 携带认证 token(从 localStorage 获取,与 apiClient 拦截器一致)
const token = localStorage.getItem("access_token")
if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`)
}
xhr.timeout = 10 * 60 * 1000 // 10 分钟超时
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText))
} catch {
reject(new Error("服务器返回数据解析失败"))
}
} else {
try {
const err = JSON.parse(xhr.responseText)
reject(new Error(err.detail || err.message || `提取失败: HTTP ${xhr.status}`))
} catch {
reject(new Error(`提取失败: HTTP ${xhr.status}`))
}
}
}
xhr.onerror = () => reject(new Error("网络错误,请检查网络连接"))
xhr.ontimeout = () => reject(new Error("上传超时(10分钟),请检查网络或尝试更小的文件"))
xhr.send(formData)
})
}
@@ -297,7 +297,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
buttonSize="sm"
onClick={() => {
handleClose()
navigate("/app/voices?tab=material&upload=1")
navigate("/app/voice-materials")
}}
>
@@ -3,11 +3,9 @@ import { useQuery } from "@tanstack/react-query"
import {
getAssetLibraries,
getAssets,
ensureDefaultLibrary,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
} from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/**
@@ -18,18 +16,7 @@ export function useAssetsData() {
/* ── 视频库列表查询 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"],
queryFn: async () => {
const libs = await getAssetLibraries()
// 如果没有 video 类型的库,自动创建默认视频素材库(与 useVoiceMaterials 保持一致)
const hasVideoLib = libs.some((lib) => lib.kind === "video")
if (!hasVideoLib) {
const project = await getOrCreateDefaultProject()
await ensureDefaultLibrary({ project_id: project.id, kind: "video" })
// 创建后重新拉取最新列表
return getAssetLibraries()
}
return libs
},
queryFn: getAssetLibraries,
staleTime: 60_000,
})
@@ -98,7 +98,7 @@ const DuplicationDetail: React.FC = () => {
<div className="dup-detail-grid">
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
<InfoCard detail={detail} />
<SegmentsSection segments={detail.segments} totalDuration={detail.duration_seconds} />
<SegmentsSection segments={detail.segments} />
</div>
</div>
)
@@ -1,7 +1,7 @@
import React from "react"
import { Button, Tag, Tooltip } from "@/components/ui"
import type { DuplicationRecord } from "@/api/duplication"
import { STATUS_CONFIG, RISK_TAG_VARIANT, RISK_LABELS } from "../constants"
import { STATUS_CONFIG } from "../constants"
import { getRiskLevel, formatSize, formatDuration } from "../utils"
interface ResultCardProps {
@@ -54,9 +54,6 @@ const ResultCard: React.FC<ResultCardProps> = ({ record, onView, onDelete, onRet
/>
</div>
<span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span>
<Tag variant={RISK_TAG_VARIANT[riskLevel]} className="dup-score-risk-tag">
{RISK_LABELS[riskLevel]}
</Tag>
</>
) : record.status === "failed" ? (
<Tooltip title="重新查重">
@@ -2,81 +2,34 @@ import React from "react"
import { Tag } from "@/components/ui"
import type { DuplicateSegment } from "@/api/duplication"
import { SegmentCard } from "./SegmentCard"
import { formatTime } from "../utils"
interface SegmentsSectionProps {
segments?: DuplicateSegment[]
/** 视频总时长(秒),用于渲染时间轴 */
totalDuration?: number
}
/** 片段相似度 → 风险等级(时间轴配色用) */
const getSegmentRisk = (similarity: number): "low" | "medium" | "high" => {
if (similarity >= 90) return "high"
if (similarity >= 70) return "medium"
return "low"
}
/**
* 重复片段列表区域(含时间轴可视化)
* 重复片段列表区域
*/
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({
segments = [],
totalDuration,
}) => {
const showTimeline = segments.length > 0 && totalDuration !== undefined && totalDuration > 0
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{segments.length}
</Tag>
</h3>
return (
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{segments.length}
</Tag>
</h3>
{showTimeline && (
<div className="dup-timeline">
<div className="dup-timeline-bar">
{segments.map((seg, i) => {
const left = (seg.source_start / totalDuration) * 100
const width = Math.max(
((seg.source_end - seg.source_start) / totalDuration) * 100,
0.5,
)
const segRisk = getSegmentRisk(seg.similarity)
return (
<div
key={seg.id ?? i}
className={`dup-timeline-segment ${segRisk}`}
style={{
left: `${Math.min(left, 100)}%`,
width: `${Math.min(width, 100 - Math.min(left, 100))}%`,
}}
title={`${formatTime(seg.source_start)} - ${formatTime(seg.source_end)} · 相似度 ${seg.similarity.toFixed(0)}% · ${seg.matched_video_name}`}
/>
)
})}
</div>
<div className="dup-timeline-labels">
<span>0s</span>
<span>{formatTime(totalDuration ?? 0)}</span>
</div>
</div>
)}
{segments.length > 0 ? (
<div className="dup-checks-list">
{segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
)
}
{segments.length > 0 ? (
<div className="dup-checks-list">
{segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
)
@@ -831,61 +831,3 @@
font-size: 16px;
}
}
/* ============================================================
查重率风险标签(列表卡片)
============================================================ */
.dup-score-risk-tag {
flex-shrink: 0;
margin-left: 2px;
}
/* ============================================================
重复片段时间轴可视化(#1662)
============================================================ */
.dup-timeline {
margin: 16px 0;
padding: 0 8px;
}
.dup-timeline-bar {
position: relative;
height: 24px;
background: var(--bg-secondary, #f1f5f9);
border-radius: 4px;
overflow: hidden;
}
.dup-timeline-segment {
position: absolute;
top: 2px;
height: 20px;
border-radius: 3px;
opacity: 0.8;
cursor: pointer;
transition: opacity 0.2s;
}
.dup-timeline-segment:hover {
opacity: 1;
}
.dup-timeline-segment.low {
background: #22c55e;
}
.dup-timeline-segment.medium {
background: #f59e0b;
}
.dup-timeline-segment.high {
background: #ef4444;
}
.dup-timeline-labels {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
+3 -3
View File
@@ -1,9 +1,9 @@
/** 根据查重率获取风险等级 */
export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low"
if (rate < 15) return "low" // <15% 绿色(安全)
if (rate <= 30) return "medium" // 15-30% 黄色(注意)
return "high" // >30% 红色(危险)
if (rate <= 10) return "low"
if (rate <= 30) return "medium"
return "high"
}
/** 格式化时间(秒 → mm:ss */
@@ -141,7 +141,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
@@ -153,45 +152,32 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
},
[onTitlePositionChange],
)
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
if (titleDragRef.current) {
const handleTitlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const xpct = (relX / rect.width) * 100
const ypct = (relY / rect.height) * 100
titleDragRef.current.style.left = `${xpct}%`
titleDragRef.current.style.top = `${ypct}%`
}
}, [])
const handleTitlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
},
[onTitlePositionChange, playRes.width, playRes.height],
)
const handleTitlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
}, [])
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0)
@@ -608,7 +594,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: "auto",
cursor: onTitlePositionChange ? "grab" : "default",
touchAction: "none",
userSelect: "none",
@@ -616,7 +601,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}}
ref={titleDragRef}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
@@ -134,7 +134,7 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
/** 跳转到配音库上传 */
const handleGoToUpload = useCallback(() => {
navigate("/app/voices?tab=material&upload=1")
navigate("/app/voices")
}, [navigate])
// 加载中状态
@@ -1,7 +1,5 @@
/**
* 标题预设样式网格
* 双图层渲染:底层=描边轮廓(text-shadow模拟),上层=填充色
* 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题
*/
import React from "react"
import { getFontFamily } from "../../constants"
@@ -9,10 +7,7 @@ import { getFontFamily } from "../../constants"
interface TitlePresetItem {
key: string
label: string
previewStyle: React.CSSProperties & {
_strokeColor?: string
_strokeWidth?: number
}
previewStyle: React.CSSProperties
}
interface TitlePresetsGridProps {
@@ -22,32 +17,6 @@ interface TitlePresetsGridProps {
fontFamily?: string
}
/**
* 用 text-shadow 模拟描边轮廓(8方向 + 4对角 = 12层阴影)
*/
function buildStrokeShadow(color: string, width: number): string {
const w = width
const parts: string[] = []
// 4 cardinal directions
parts.push(`${w}px 0 ${color}`)
parts.push(`${-w}px 0 ${color}`)
parts.push(`0 ${w}px ${color}`)
parts.push(`0 ${-w}px ${color}`)
// 4 diagonal directions
const d = Math.round(w * 0.71 * 10) / 10 // 0.71 ≈ sqrt(2)/2
parts.push(`${d}px ${d}px ${color}`)
parts.push(`${-d}px ${d}px ${color}`)
parts.push(`${d}px ${-d}px ${color}`)
parts.push(`${-d}px ${-d}px ${color}`)
// 4 extra mid-points for smoother stroke
const h = Math.round(w * 0.5 * 10) / 10
parts.push(`${w}px ${h}px ${color}`)
parts.push(`${w}px ${-h}px ${color}`)
parts.push(`${-w}px ${h}px ${color}`)
parts.push(`${-w}px ${-h}px ${color}`)
return parts.join(", ")
}
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
presets,
activePreset,
@@ -58,52 +27,18 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
<div className="xx-title-presets-grid">
{presets.map((p) => {
const isActive = activePreset === p.key
const { _strokeColor, _strokeWidth, ...fillStyle } = p.previewStyle
const ff = getFontFamily(fontFamily || "思源黑体")
// 底层:描边轮廓(用 text-shadow 模拟粗描边)
const strokeStyle: React.CSSProperties = {
color: _strokeColor || "transparent",
textShadow:
_strokeColor && _strokeWidth
? buildStrokeShadow(_strokeColor, _strokeWidth)
: undefined,
fontWeight: fillStyle.fontWeight,
fontSize: fillStyle.fontSize,
lineHeight: 1,
}
// 上层:仅填充色 + 可选 textShadow(发光/投影效果)
const topStyle: React.CSSProperties = {
color: fillStyle.color,
textShadow: fillStyle.textShadow,
fontWeight: fillStyle.fontWeight,
fontSize: fillStyle.fontSize,
lineHeight: 1,
}
return (
<button
key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)}
title={p.label}
>
<span className="xx-title-preset-preview-text" style={{ position: "relative" }}>
{/* 底层:描边轮廓 */}
<span
aria-hidden
style={{
...strokeStyle,
fontFamily: ff,
position: "absolute",
top: 0,
left: 0,
}}
>
T
</span>
{/* 上层:填充色 */}
<span style={{ ...topStyle, fontFamily: ff, position: "relative" }}>T</span>
<span
className="xx-title-preset-preview-text"
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
>
T
</span>
</button>
)
+16 -218
View File
@@ -78,11 +78,10 @@ export const TITLE_PRESETS = [
label: "经典白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ffffff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
@@ -90,54 +89,45 @@ export const TITLE_PRESETS = [
label: "黑金质感",
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#d4a843",
fontWeight: 700,
fontSize: "32px",
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#e8e8e8",
fontWeight: 400,
fontSize: "32px",
},
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
},
{
key: "variety_show",
label: "综艺花字",
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: {
color: "#ff4081",
_strokeColor: "#ffffff",
_strokeWidth: 2,
fontWeight: 900,
fontSize: "32px",
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#e0e0e0",
fontWeight: 400,
fontSize: "32px",
},
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
},
{
key: "retro_film",
label: "复古胶片",
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#e8d5b7",
fontWeight: 400,
fontSize: "32px",
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
@@ -145,10 +135,10 @@ export const TITLE_PRESETS = [
label: "霓虹发光",
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#00e5ff",
fontWeight: 700,
fontSize: "32px",
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
@@ -156,202 +146,10 @@ export const TITLE_PRESETS = [
label: "手写字",
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#e0e0e0",
fontWeight: 400,
fontSize: "32px",
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
},
},
{
key: "outline_yellow",
label: "黄色描边",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ffd54f",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_pink",
label: "粉色描边",
style: { size: 28, color: "#ff80ab", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ff80ab",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_blue",
label: "蓝色描边",
style: { size: 28, color: "#82b1ff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#82b1ff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_green",
label: "绿色描边",
style: { size: 28, color: "#69f0ae", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#69f0ae",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_gray",
label: "灰色描边",
style: { size: 28, color: "#bdbdbd", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#bdbdbd",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "bg_white",
label: "白底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffffff",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_yellow",
label: "黄底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffd54f",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_pink",
label: "粉底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ff80ab",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_red",
label: "红底白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#ef5350",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "neon_orange",
label: "橙色发光",
style: { size: 32, color: "#ff9100", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff9100",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #ff9100, 0 0 8px #ff9100, 0 0 16px rgba(255,145,0,0.5)",
},
},
{
key: "neon_purple",
label: "紫色发光",
style: { size: 32, color: "#d500f9", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#d500f9",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #d500f9, 0 0 8px #d500f9, 0 0 16px rgba(213,0,249,0.5)",
},
},
{
key: "bordered_white",
label: "白字绿框",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#1a1a1a",
border: "2px solid #69f0ae",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "gradient_warm",
label: "暖色渐变",
style: { size: 32, color: "#ff6d00", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff6d00",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,109,0,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "gradient_cool",
label: "冷色渐变",
style: { size: 32, color: "#00b0ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#00b0ff",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(0,176,255,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "shadow_deep",
label: "深影白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
textShadow: "2px 2px 4px rgba(0,0,0,0.8), 0 0 8px rgba(0,0,0,0.4)",
},
},
{
key: "soft_gold",
label: "柔光金",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffd54f",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,213,79,0.5), 1px 1px 2px rgba(0,0,0,0.4)",
fontSize: "20px",
},
},
]
+12 -12
View File
@@ -1733,36 +1733,36 @@
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
.xx-title-preset-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
aspect-ratio: 1 / 1;
padding: 4px;
background: var(--bg-secondary);
border: 2px solid transparent;
border-radius: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
border-color: var(--primary-200);
background: var(--bg-primary);
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
border-color: var(--primary-color);
background: var(--primary-50);
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
@@ -221,7 +221,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
product.duplicateRate > 0 ? ` ${dupClass}` : ""
}`}
>
{product.duplicateRate != null ? `${product.duplicateRate.toFixed(1)}%` : "-"}
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
@@ -2,7 +2,6 @@ import React from "react"
import type { ProductItem } from "../../../api/products"
import { STATUS_MAP } from "../constants"
import { formatDuration, formatFileSize, formatDate } from "../detailUtils"
import { getRiskLevel } from "../../duplication/utils"
interface ProductInfoPanelProps {
product: ProductItem
@@ -45,26 +44,12 @@ export const ProductInfoPanel: React.FC<ProductInfoPanelProps> = ({ product }) =
</div>
<div className="xx-detail-meta-item">
<span className="xx-detail-meta-label"></span>
<span
className={`xx-detail-meta-value dup-risk-text dup-risk-${getRiskLevel(product.duplicate_rate)}`}
>
<span className="xx-detail-meta-value">
{(product.duplicate_rate ?? 0) > 0
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
: "-"}
</span>
</div>
{product.visual_similarity != null && (
<div className="xx-detail-meta-item">
<span className="xx-detail-meta-label"></span>
<span className="xx-detail-meta-value">{product.visual_similarity.toFixed(1)}%</span>
</div>
)}
{product.match_count != null && (
<div className="xx-detail-meta-item">
<span className="xx-detail-meta-label"></span>
<span className="xx-detail-meta-value">{product.match_count}</span>
</div>
)}
<div className="xx-detail-meta-item">
<span className="xx-detail-meta-label"></span>
<span className="xx-detail-meta-value">{formatDate(product.created_at ?? "")}</span>
+27 -34
View File
@@ -91,6 +91,8 @@
height: 18px;
border: 2px solid var(--border-color);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
transition: var(--transition-all);
background: var(--bg-primary);
flex-shrink: 0;
@@ -135,7 +137,7 @@
============================================================ */
.xx-products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
}
@@ -185,6 +187,8 @@
height: 22px;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
cursor: pointer;
@@ -258,8 +262,11 @@
.xx-product-thumb {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: var(--text-inverse);
background: var(--color-gray-950);
max-height: 320px;
}
.xx-product-thumb-bg {
@@ -282,28 +289,22 @@
}
.xx-product-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
width: 52px;
height: 52px;
border-radius: 50%;
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
transition: all 0.2s;
cursor: pointer;
display: grid;
place-items: center;
font-size: var(--font-size-md);
transition: var(--transition-all);
}
.xx-product-card:hover .xx-product-play {
background: var(--primary-color);
transform: translate(-50%, -50%) scale(1.1);
transform: scale(1.1);
}
/* 时长标签 */
@@ -323,7 +324,7 @@
/* 卡片信息区 */
.xx-product-info {
padding: 8px 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: var(--space-sm);
@@ -505,6 +506,8 @@
z-index: 1000;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
animation: player-fade-in 0.25s ease-out;
}
@@ -543,6 +546,8 @@
background: var(--color-gray-950);
aspect-ratio: 9 / 16;
max-height: 60vh;
display: grid;
place-items: center;
}
.xx-player-video-wrap video {
@@ -564,6 +569,8 @@
backdrop-filter: blur(4px);
color: var(--text-inverse);
font-size: 28px;
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
@@ -660,6 +667,8 @@
backdrop-filter: blur(4px);
color: var(--text-inverse);
font-size: var(--font-size-md);
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
@@ -1076,19 +1085,3 @@
gap: var(--space-sm);
}
}
/* 查重率风险颜色(#1662) */
.xx-detail-meta-value.dup-risk-low {
color: var(--success-color, #22c55e);
font-weight: 600;
}
.xx-detail-meta-value.dup-risk-medium {
color: var(--warning-color, #f59e0b);
font-weight: 600;
}
.xx-detail-meta-value.dup-risk-high {
color: var(--error-color, #ef4444);
font-weight: 600;
}
@@ -1,11 +1,6 @@
import { useMemo, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
getAssetsByKind,
getAssetLibraries,
createAssetLibrary,
type AssetItem,
} from "@/api/assets"
import { getAssetsByKind, getAssetLibraries, createAssetLibrary } from "@/api/assets"
import { type VoiceMaterial, mapAssetToMaterial } from "../../types"
interface UseVoiceMaterialDataOptions {
@@ -49,15 +44,6 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
queryKey: ["assets", "voice", { keyword, gender, tag_ids: tagIds }],
queryFn: () => getAssetsByKind("voice", { keyword, gender, tag_ids: tagIds }),
staleTime: 30_000,
// 列表中存在上传中/处理中素材时每 3s 轮询;全部就绪后自动停止
refetchInterval: (query) => {
const items = (query.state.data as AssetItem[] | undefined) ?? []
const processing = items.some((a) => {
const st = a.status ?? ""
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
})
return processing ? 3000 : false
},
})
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
+1 -1
View File
@@ -41,7 +41,7 @@ export const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [],
fileName: asset.storage_key?.split("/").pop() || asset.name,
fileSize: asset.file_size || 0,
duration: asset.duration || (meta.duration as number) || 0,
duration: (meta.duration as number) || 0,
mimeType: asset.mime_type || "audio/mpeg",
createdAt: asset.created_at || new Date().toISOString(),
fileUrl: asset.file_url,
+3 -56
View File
@@ -14,14 +14,8 @@
* 弹窗集合 → components/VoiceModals
* Toast 提示 → components/VoiceToasts
*/
import React, { useCallback, useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"
import {
UploadOutlined,
AudioOutlined,
RobotOutlined,
VideoCameraOutlined,
} from "@ant-design/icons"
import React, { useCallback, useState } from "react"
import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
import { type AssetItem } from "@/api/assets"
@@ -40,8 +34,6 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload"
import { useMaterialDelete } from "./hooks/useMaterialDelete"
import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete"
import { useVideoExtract } from "./hooks/useVideoExtract"
import VideoExtractModal from "./components/VideoExtractModal"
import "./voices.css"
let toastIdSeq = 0
@@ -166,32 +158,6 @@ const VoiceLibrary: React.FC = () => {
handleUploadClose,
} = useVoiceUpload({ showToast })
// ── 提取视频配音 ──────────────────────────────────────
const {
extractOpen,
extractFile,
extractProgress,
isExtracting,
setExtractOpen,
handleFileSelect: handleExtractFileSelect,
handleExtract,
handleExtractClose,
} = useVideoExtract({ showToast })
// ── URL 参数自动打开上传弹窗 ────────────────────────────
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
if (searchParams.get("upload") === "1") {
setActiveTab("material")
setUploadOpen(true)
// 一次性触发器:清理 upload 参数,避免切换 Tab 时重复触发
const next = new URLSearchParams(searchParams)
next.delete("upload")
setSearchParams(next, { replace: true })
}
}, [searchParams, setActiveTab, setUploadOpen, setSearchParams])
// ── 切换 Tab 时停止播放 ───────────────────────────────
const handleTabChange = useCallback(
(tab: VoiceTabKey) => {
@@ -219,14 +185,6 @@ const VoiceLibrary: React.FC = () => {
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<VideoCameraOutlined />}
onClick={() => setExtractOpen(true)}
>
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
@@ -327,18 +285,7 @@ const VoiceLibrary: React.FC = () => {
/>
)}
{/* ── 视频提取配音弹窗 ─────────────────────────────── */}
<VideoExtractModal
open={extractOpen}
file={extractFile}
progress={extractProgress}
isExtracting={isExtracting}
onClose={handleExtractClose}
onFileSelect={handleExtractFileSelect}
onExtract={handleExtract}
/>
{/* ── 弹窗集合 ─────────────────────────────────── */}
{/* ── 弹窗集合 ──────────────────────────────────── */}
<VoiceModals
cloneModalOpen={cloneModalOpen}
onCloneClose={() => setCloneModalOpen(false)}
@@ -136,9 +136,6 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
const material = mapAssetToMaterial(asset)
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
const cardDuration = asset.duration || material.duration || 0
// AI 生成素材标识:兼容旧素材(无 source 字段但有 tts_job_id
const meta = asset.metadata as Record<string, unknown>
const isAiMaterial = meta?.source === "tts_job" || !!meta?.tts_job_id
const isPlaying = playingId === asset.id
const isSelected = selectedIds.has(asset.id)
// 播放中以 audio 真实时长为准,未播放显示卡片时长
@@ -185,11 +182,8 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
</div>
<div className="xx-voice-info vmat-info">
<div className="xx-voice-name-row">
<div className="xx-voice-name" title={asset.name}>
{asset.name}
</div>
{isAiMaterial && <span className="vmat-ai-badge">AI</span>}
<div className="xx-voice-name" title={asset.name}>
{asset.name}
</div>
<div className="xx-voice-subtitle">
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
@@ -1,207 +0,0 @@
import React, { useRef } from "react"
import { Modal } from "antd"
import { InboxOutlined, CloseOutlined } from "@ant-design/icons"
interface VideoExtractModalProps {
open: boolean
file: File | null
progress: number | null
isExtracting: boolean
onClose: () => void
onFileSelect: (file: File | null) => void
onExtract: () => void
}
const ACCEPT_TYPES = ".mp4,.mov,.webm"
const VideoExtractModal: React.FC<VideoExtractModalProps> = ({
open,
file,
progress,
isExtracting,
onClose,
onFileSelect,
onExtract,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
return (
<Modal
title={<span style={{ fontSize: 16, fontWeight: 600 }}></span>}
open={open}
onCancel={() => {
if (isExtracting) return
onClose()
}}
footer={null}
width={480}
maskClosable={!isExtracting}
>
{!file ? (
<div
className="vmat-upload-dropzone"
onClick={() => inputRef.current?.click()}
style={{
border: "2px dashed #d9d9d9",
borderRadius: 8,
padding: "40px 20px",
textAlign: "center",
cursor: "pointer",
transition: "border-color 0.3s",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#7c3aed")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#d9d9d9")}
>
<InboxOutlined style={{ fontSize: 32, color: "#7c3aed", marginBottom: 12 }} />
<p style={{ margin: "0 0 8px", fontSize: 14, color: "#333" }}></p>
<span style={{ fontSize: 12, color: "#999" }}> MP4MOVWebM </span>
<input
ref={inputRef}
type="file"
accept={ACCEPT_TYPES}
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onFileSelect(f)
}}
/>
</div>
) : (
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
background: "#fafafa",
borderRadius: 8,
marginBottom: 16,
}}
>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: 14,
fontWeight: 500,
}}
title={file.name}
>
{file.name}
</span>
<span style={{ fontSize: 12, color: "#999", marginLeft: 8, flexShrink: 0 }}>
{(file.size / (1024 * 1024)).toFixed(1)} MB
</span>
{!isExtracting && (
<button
type="button"
onClick={() => {
if (inputRef.current) inputRef.current.value = ""
onFileSelect(null)
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#999",
marginLeft: 8,
fontSize: 14,
}}
aria-label="移除文件"
>
<CloseOutlined />
</button>
)}
</div>
{progress !== null && (
<div style={{ marginBottom: 12 }}>
<div
style={{
height: 6,
background: "#f0f0f0",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background: "linear-gradient(90deg, #7c3aed, #a78bfa)",
borderRadius: 3,
transition: "width 0.3s",
}}
/>
</div>
<div
style={{
textAlign: "right",
fontSize: 12,
color: "#999",
marginTop: 4,
}}
>
{progress}%
</div>
</div>
)}
{isExtracting && (
<p style={{ textAlign: "center", fontSize: 13, color: "#7c3aed", margin: "12px 0 0" }}>
{progress === 100 ? "正在提取人声,请稍候..." : "正在上传视频..."}
</p>
)}
</div>
)}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 24,
}}
>
<button
type="button"
onClick={onClose}
disabled={isExtracting}
style={{
padding: "6px 16px",
borderRadius: 6,
border: "1px solid #d9d9d9",
background: "#fff",
cursor: isExtracting ? "not-allowed" : "pointer",
fontSize: 14,
opacity: isExtracting ? 0.5 : 1,
}}
>
</button>
<button
type="button"
onClick={onExtract}
disabled={!file || isExtracting}
style={{
padding: "6px 16px",
borderRadius: 6,
border: "none",
background: !file || isExtracting ? "#d9d9d9" : "#7c3aed",
color: "#fff",
cursor: !file || isExtracting ? "not-allowed" : "pointer",
fontSize: 14,
fontWeight: 500,
}}
>
{isExtracting ? "提取中..." : "开始提取"}
</button>
</div>
</Modal>
)
}
export default VideoExtractModal
@@ -1,74 +0,0 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { extractVideoVoice } from "@/api/tts"
/**
* 视频提取配音 Hook
* 封装视频上传弹窗状态、提取进度、提取 mutation 逻辑
*/
interface UseVideoExtractProps {
showToast: (message: string, type: "success" | "error") => void
}
export function useVideoExtract({ showToast }: UseVideoExtractProps) {
const queryClient = useQueryClient()
const [extractOpen, setExtractOpen] = useState(false)
const [extractFile, setExtractFile] = useState<File | null>(null)
const [extractProgress, setExtractProgress] = useState<number | null>(null)
const [isExtracting, setIsExtracting] = useState(false)
const handleExtractClose = useCallback(() => {
setExtractOpen(false)
setExtractFile(null)
setExtractProgress(null)
setIsExtracting(false)
}, [])
const handleExtract = useCallback(async () => {
if (!extractFile) return
setIsExtracting(true)
setExtractProgress(0)
try {
await extractVideoVoice(extractFile, (p) => setExtractProgress(p))
// 刷新素材列表
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
showToast("视频配音提取成功", "success")
handleExtractClose()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "提取失败,请重试"
showToast(msg, "error")
} finally {
setIsExtracting(false)
setExtractProgress(null)
}
}, [extractFile, queryClient, showToast, handleExtractClose])
const handleFileSelect = useCallback(
(file: File | null) => {
if (!file) {
setExtractFile(null)
return
}
const validTypes = ["video/mp4", "video/quicktime", "video/webm"]
if (!validTypes.includes(file.type)) {
showToast("仅支持 MP4、MOV、WebM 格式的视频文件", "error")
return
}
setExtractFile(file)
},
[showToast],
)
return {
extractOpen,
setExtractOpen,
extractFile,
extractProgress,
isExtracting,
handleFileSelect,
handleExtract,
handleExtractClose,
}
}
-18
View File
@@ -193,24 +193,6 @@
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
display: flex;
align-items: center;
}
/* AI 配音标识 */
.vmat-ai-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #7c3aed;
background: #f3f0ff;
border: 1px solid #ddd6fe;
border-radius: 4px;
line-height: 16px;
vertical-align: middle;
flex-shrink: 0;
}
.xx-voice-star {
@@ -1,29 +0,0 @@
import { describe, it, expect } from "vitest"
import { getRiskLevel } from "@/pages/duplication/utils"
describe("getRiskLevel (#1662 阈值 <15 / 15-30 / >30)", () => {
it("undefined 返回 low(兼容无数据)", () => {
expect(getRiskLevel(undefined)).toBe("low")
})
it("<15% 为低风险", () => {
expect(getRiskLevel(0)).toBe("low")
expect(getRiskLevel(10)).toBe("low")
expect(getRiskLevel(14.9)).toBe("low")
})
it("15% 边界为中风险", () => {
expect(getRiskLevel(15)).toBe("medium")
})
it("15-30% 为中风险", () => {
expect(getRiskLevel(20)).toBe("medium")
expect(getRiskLevel(30)).toBe("medium")
})
it(">30% 为高风险", () => {
expect(getRiskLevel(30.1)).toBe("high")
expect(getRiskLevel(80)).toBe("high")
expect(getRiskLevel(100)).toBe("high")
})
})
File diff suppressed because it is too large Load Diff
+6 -37
View File
@@ -100,24 +100,8 @@ def create_video_record_and_dedup(
generated_video.video_fingerprint = fingerprint.to_dict()
# 写入分片指纹表
from video_processing.dedup import _save_fingerprint_chunks
try:
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
except Exception as chunk_err:
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0
duplicate_result = deduplicator.check_duplicate(
fingerprint,
project_id,
session,
scope="user",
user_id=user_id,
duration_sec=duration_sec,
)
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (b) 批次内查重(仅当有 batch_id 时)
if not duplicate_result and batch_id:
@@ -137,26 +121,11 @@ def create_video_record_and_dedup(
generated_video.is_duplicate = False
generated_video.duplicate_of = None
# 计算重复率百分比(项目全局
# 计算重复率百分比(项目内所有已有视频对比取最高相似度
try:
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id,
video_id,
session,
scope="user",
user_id=user_id,
)
generated_video.duplicate_rate = rate_result["duplicate_rate"]
generated_video.match_count = rate_result["match_count"]
generated_video.visual_similarity = rate_result["visual_similarity"]
logger.info(
"Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)",
video_id,
rate_result["duplicate_rate"],
rate_result["visual_similarity"],
rate_result["match_count"],
)
dup_rate = deduplicator.compute_duplicate_rate(fingerprint, project_id, video_id, session)
generated_video.duplicate_rate = dup_rate
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
except Exception as rate_err:
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
generated_video.duplicate_rate = None
@@ -200,22 +200,6 @@ class UnifiedRenderService:
# 3. 计算视频总时长(用于字幕显示时长)
video_duration = self._estimate_total_duration(layers)
# Debug: 输出各图层时长明细
for layer in layers:
layer_total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in layer.clips)
clip_details = [
f"{c.clip_id}(dur={c.duration:.3f},actual={c.actual_duration:.3f},speed={getattr(c, 'playback_speed', 1.0):.4f})"
for c in layer.clips
]
logger.info(
"[debug] layer=%s clips=%d total=%.3f transition_duration=%.3f details=%s",
layer.role,
len(layer.clips),
layer_total,
self.transition_duration,
", ".join(clip_details),
)
logger.info("[debug] estimated video_duration=%.3f", video_duration)
# 3.5 TTS 配音生成(如果配置了)
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
@@ -1438,7 +1422,6 @@ class UnifiedRenderService:
if trim_segments and len(trim_segments) > 1:
# 多段裁剪:展开为多个 clip
resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration)
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
for i, seg in enumerate(resolved_segments):
# 每个段生成一个独立的 ResolvedClip
seg_clip_id = f"{clip.id}_seg_{seg.segment_id}"
@@ -1446,19 +1429,6 @@ class UnifiedRenderService:
seg_start = seg.trim.start_time
seg_duration = seg.trim.duration
# 多段裁剪:如果段的时长超过素材实际时长,减速补偿
seg_speed = configured_speed
if actual_duration > 0 and seg_duration > actual_duration + 0.05:
seg_speed = max(0.25, round(configured_speed * actual_duration / seg_duration, 4))
logger.info(
"[debug] multi-seg clip=%s seg=%s duration=%.3f actual=%.3f → speed=%.4f",
clip.id,
seg.segment_id,
seg_duration,
actual_duration,
seg_speed,
)
rc = ResolvedClip(
clip_id=seg_clip_id,
asset_id=asset_id,
@@ -1469,7 +1439,7 @@ class UnifiedRenderService:
duration=seg_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=seg_speed,
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
config={**clip_config, "_segment_id": seg.segment_id},
actual_duration=actual_duration,
trim_config=seg.trim,
@@ -1491,7 +1461,6 @@ class UnifiedRenderService:
effective_trim: TrimConfig | None = None
final_start = clip.start_time
final_duration = clip.duration
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
if trim_config is not None and actual_duration > 0:
effective_trim = trim_config.validate_and_resolve(actual_duration)
@@ -1505,25 +1474,6 @@ class UnifiedRenderService:
final_start = 0.0
final_duration = actual_duration
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
# 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出
if actual_duration > 0 and final_duration > actual_duration + 0.05:
compensated_speed = actual_duration / final_duration
# 保留用户设置的速度(如果已减速则叠加)
final_speed = configured_speed * compensated_speed
# 下限 0.25x
final_speed = max(0.25, round(final_speed, 4))
logger.info(
"[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)",
clip.id,
final_duration,
actual_duration,
final_speed,
configured_speed,
)
else:
final_speed = configured_speed
rc = ResolvedClip(
clip_id=clip.id,
asset_id=asset_id,
@@ -1534,25 +1484,13 @@ class UnifiedRenderService:
duration=final_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=final_speed,
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
config=clip_config,
actual_duration=actual_duration,
trim_config=effective_trim,
)
resolved.append(rc)
# Debug日志:记录每个clip的时长信息
eff_dur = _clip_effective_duration_pure(final_duration, actual_duration)
logger.info(
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
clip.id,
final_duration,
actual_duration,
eff_dur,
final_speed,
final_start,
)
# 按 order 排序
resolved.sort(key=lambda c: c.order)
return resolved
@@ -1745,7 +1683,7 @@ class UnifiedRenderService:
if d > 0:
layer_dur = d
break
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
@@ -1754,13 +1692,6 @@ class UnifiedRenderService:
)
if xfade_filter:
filter_parts.append(xfade_filter)
logger.info(
"[unified-render] layer=%s xfade: clips=%d durations=%s estimated_dur=%.3f",
layer.role,
len(layer_labels),
[round(d, 3) for d in layer_durations],
xfade_estimated_dur,
)
layer_output_labels[layer.role] = out_label
# Step 3: 合成各层
@@ -1984,14 +1915,8 @@ class UnifiedRenderService:
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)。
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration,
而非 min(duration, actual_duration)。
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
"""
speed = getattr(clip, "playback_speed", 1.0) or 1.0
# 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration
if speed < 1.0 - 1e-6 and clip.duration > 0:
return clip.duration
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
-1
View File
@@ -15,7 +15,6 @@ celery_app.conf.imports = (
"worker_app.tasks.voice_clone",
"worker_app.tasks.tts_synthesis",
"worker_app.tasks.batch_download",
"worker_app.tasks.duplication_check",
"worker_app.tasks._startup",
"apps.worker.video_processing.dedup",
"worker_app.tasks.cleanup",
@@ -1,196 +0,0 @@
"""手动查重任务(Issue #1661)。
流程:
1. 从 OSS 下载用户上传的待查重视频
2. 动态抽帧计算指纹(复用 VideoDeduplicator.compute_fingerprint
3. 跨项目与用户所有已有成片比对(compute_duplicate_rate + find_duplicate_segments
4. 更新 DuplicationRecordstatus / duplicate_rate / duplicate_count / segments
同时写入 visual_similarity / match_count
5. 失败重试 3 次、间隔 60 秒,最终失败标记 failed;临时文件始终清理
"""
import logging
import os
import shutil
import tempfile
from celery import Task
from celery.exceptions import Retry
from video_processing.dedup import (
VideoDeduplicator,
find_duplicate_segments,
)
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.duplication_repository import (
SQLAlchemyDuplicationRecordRepository,
)
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.duplication import DuplicateSegment
from packages.shared.storage import get_storage_service
logger = logging.getLogger(__name__)
def _build_domain_segments(
fingerprint,
session,
deduplicator: VideoDeduplicator,
user_id: str,
) -> tuple[list[DuplicateSegment], int]:
"""对用户所有已有视频做分片级时序匹配,构建领域片段列表。
Returns:
(segments, duplicate_count) — segments 为 query 视频中的重复片段,
duplicate_count 为存在重复片段的匹配视频数。
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
existing_videos = video_repo.list_by_user(user_id)
segments_out: list[DuplicateSegment] = []
duplicate_count = 0
for existing in existing_videos:
if not existing.video_fingerprint:
continue
chunk_data = deduplicator._get_existing_chunks(existing.id, session)
if not chunk_data:
# 老视频无分片数据,时序定位不可靠,跳过片段级匹配
continue
raw_segments = find_duplicate_segments(fingerprint.chunks, chunk_data)
if not raw_segments:
continue
duplicate_count += 1
for raw in raw_segments:
avg_sim = 1.0 - raw.avg_distance / 64.0
segments_out.append(
DuplicateSegment.create(
source_start=round(raw.query_start_ms / 1000.0, 2),
source_end=round(raw.query_end_ms / 1000.0, 2),
matched_video_id=existing.id,
matched_video_name=existing.name,
matched_start=round(raw.target_start_ms / 1000.0, 2),
matched_end=round(raw.target_end_ms / 1000.0, 2),
similarity=round(max(0.0, min(1.0, avg_sim)) * 100, 1),
)
)
# 按 query 起始时间排序,片段时间轴稳定
segments_out.sort(key=lambda s: (s.source_start, s.source_end))
return segments_out, duplicate_count
@celery_app.task(bind=True, max_retries=3, name="worker.process_duplication_check")
def process_duplication_check(self: Task, record_id: str) -> dict:
"""处理一次手动查重请求。
Args:
record_id: DuplicationRecord ID
Returns:
dict: {"ok": True, "record_id": ..., "duplicate_rate": ..., ...}
"""
session = None
temp_dir = None
try:
session = SessionLocal()
repo = SQLAlchemyDuplicationRecordRepository(session)
storage_service = get_storage_service()
deduplicator = VideoDeduplicator()
record = repo.get(record_id)
if record is None:
raise ValueError(f"Duplication record {record_id} not found")
if record.status not in ("pending", "processing"):
logger.info("Duplication record %s already %s, skip", record_id, record.status)
return {"ok": True, "record_id": record_id, "status": record.status, "skipped": True}
record.mark_processing()
repo.update(record)
session.commit()
temp_dir = tempfile.mkdtemp(prefix="dup_check_")
suffix = os.path.splitext(record.filename)[1] or ".mp4"
local_path = os.path.join(temp_dir, f"{record_id}{suffix}")
storage_service.download_file(record.storage_key, local_path)
fingerprint = deduplicator.compute_fingerprint(local_path)
record.duration_seconds = round(fingerprint.duration, 2) if fingerprint.duration else 0.0
record.video_fingerprint = fingerprint.to_dict()
# 跨项目与用户所有已有视频比对(current_video_id=None:上传视频不在成片表中)
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id="",
current_video_id=None,
session=session,
scope="user",
user_id=record.user_id,
)
# 分片级时序匹配 → 重复片段
segments, segment_match_count = _build_domain_segments(fingerprint, session, deduplicator, record.user_id)
record.mark_completed(
duplicate_rate=rate_result["duplicate_rate"],
duplicate_count=segment_match_count,
segments=segments,
visual_similarity=rate_result["visual_similarity"],
match_count=rate_result["match_count"],
)
repo.update(record)
session.commit()
logger.info(
"Duplication check completed: record=%s rate=%.2f%% matches=%d segments=%d",
record_id,
record.duplicate_rate,
record.match_count,
len(segments),
)
return {
"ok": True,
"record_id": record_id,
"status": "completed",
"duplicate_rate": record.duplicate_rate,
"duplicate_count": record.duplicate_count,
"visual_similarity": record.visual_similarity,
"match_count": record.match_count,
"segments": len(segments),
}
except Retry:
raise
except Exception as e:
logger.error("Duplication check failed for record %s: %s", record_id, e, exc_info=True)
if session is not None:
session.rollback()
# 本次是最后一次执行机会(retries 从 0 计数,达到 max_retries 说明重试已耗尽),
# 标记 failed;否则保持 pending 由 Celery 60 秒后重试
try:
if "repo" in locals() and self.request.retries >= self.max_retries:
failed_record = repo.get(record_id)
if failed_record is not None and failed_record.status != "failed":
failed_record.mark_failed(f"查重失败(已重试{self.max_retries}次): {e}")
repo.update(failed_record)
session.commit()
except Exception as inner:
logger.error("Failed to mark duplication record %s as failed: %s", record_id, inner)
session.rollback()
raise self.retry(exc=e, countdown=60) from e
finally:
if session is not None:
session.close()
if temp_dir and os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
+20 -75
View File
@@ -630,7 +630,7 @@ def ingest_asset(job_id: str) -> dict:
name=filename,
storage_key=job.storage_key,
mime_type=mime_type,
metadata={"source": "upload", "ingest_error": error_reason},
metadata={"ingest_error": error_reason},
file_size=int(metadata.get("size_bytes", 0)),
duration=float(metadata.get("duration", 0)),
width=int(metadata.get("width", 0)),
@@ -656,57 +656,24 @@ def ingest_asset(job_id: str) -> dict:
"error": error_reason,
}
# 查找已存在的 Asset 记录(由 API 端在上传完成时立即创建为 PROCESSING 状态)
existing_asset = None
try:
existing_asset = asset_repo.find_by_storage_key(job.storage_key)
except Exception:
logger.warning("find_by_storage_key not available, trying fallback lookup")
if existing_asset is None:
# 兜底:如果 API 端没有预先创建 Asset(旧版本兼容),则创建新记录
logger.info("No pre-created asset found for storage_key=%s, creating new", job.storage_key)
metadata["source"] = "upload"
asset = Asset.create(
project_id=job.project_id,
library_id=job.library_id,
name=filename,
storage_key=job.storage_key,
mime_type=mime_type,
metadata=metadata,
file_size=int(metadata.get("size_bytes", 0)),
duration=float(metadata.get("duration", 0)),
width=int(metadata.get("width", 0)),
height=int(metadata.get("height", 0)),
codec=metadata.get("codec") or None,
status=AssetStatus.READY,
file_hash=job.file_hash,
thumbnail_url=thumbnail_url,
)
asset_repo.create(asset)
else:
# 更新已有的 Asset 记录,补充元数据并将状态改为 READY
asset = existing_asset
asset.mime_type = mime_type
metadata["source"] = "upload"
asset.metadata = metadata
asset.file_size = int(metadata.get("size_bytes", 0))
asset.duration = float(metadata.get("duration", 0))
asset.width = int(metadata.get("width", 0))
asset.height = int(metadata.get("height", 0))
codec_val = metadata.get("codec")
if codec_val:
asset.codec = str(codec_val)
fps_val = metadata.get("fps")
if fps_val:
try:
asset.fps = float(fps_val)
except (ValueError, TypeError):
pass
asset.status = AssetStatus.READY
asset.thumbnail_url = thumbnail_url
asset.updated_at = datetime.now(timezone.utc)
asset_repo.update(asset)
# Create Asset
asset = Asset.create(
project_id=job.project_id,
library_id=job.library_id,
name=filename,
storage_key=job.storage_key,
mime_type=mime_type,
metadata=metadata,
file_size=int(metadata.get("size_bytes", 0)),
duration=float(metadata.get("duration", 0)),
width=int(metadata.get("width", 0)),
height=int(metadata.get("height", 0)),
codec=metadata.get("codec") or None,
status=AssetStatus.READY,
file_hash=job.file_hash,
thumbnail_url=thumbnail_url,
)
asset_repo.create(asset)
# Update job status to COMPLETED
job.status = IngestJobStatus.COMPLETED
@@ -725,37 +692,15 @@ def ingest_asset(job_id: str) -> dict:
db.rollback()
logger.error(f"Failed to ingest asset {job_id}: {e}")
# Update job status to FAILED and mark pre-created Asset as ERROR
# Update job status to FAILED
try:
job_repo = SQLAlchemyIngestJobRepository(db)
asset_repo = SQLAlchemyAssetRepository(db)
job = job_repo.get(job_id)
if job:
job.status = IngestJobStatus.FAILED
job.error_message = str(e)
job.updated_at = datetime.now(timezone.utc)
job_repo.update(job)
# 将上传时创建的占位 AssetPROCESSING/UPLOADING)标记为 ERROR
# 避免素材永远卡在中间状态
try:
existing = asset_repo.find_by_storage_key(job.storage_key)
if existing and existing.status in (
AssetStatus.PROCESSING,
AssetStatus.UPLOADING,
):
existing.status = AssetStatus.ERROR
existing.metadata = {**(existing.metadata or {}), "ingest_error": str(e)}
existing.updated_at = datetime.now(timezone.utc)
asset_repo.update(existing)
logger.info(
"Marked asset as ERROR due to ingest failure: asset_id=%s job_id=%s",
existing.id,
job_id,
)
except Exception as asset_err:
logger.warning("Failed to mark asset as ERROR: %s", asset_err)
db.commit()
except Exception:
db.rollback()
-216
View File
@@ -1,216 +0,0 @@
# ============================================================
# 小虾 SaaS — Production 环境配置模板
# ============================================================
# 使用方式:复制为 /var/lib/xiaoxia-saas-production/.env 并填入实际密钥
# 敏感值标记为 ${PLACEHOLDER},部署前必须替换为真实值
# ============================================================
# ==================== 应用基本配置 ====================
# 应用名称
APP_NAME=xiaoxia-saas
# 环境标识
APP_ENV=production
# 关闭 Debug 模式
DEBUG=false
# 应用基础 URL(前端页面地址)
APP_BASE_URL=https://xiaoxiajianji.com
# 对外公开的 API 基础 URL(用于生成回调链接等)
PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com
# API 服务监听地址
API_HOST=0.0.0.0
# API 服务监听端口
API_PORT=8001
# 生产环境关闭自动建表,使用 alembic migration
AUTO_CREATE_SCHEMA=false
# ==================== 数据库配置 ====================
# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname
# ${DATABASE_URL} — 替换为实际的 Production PostgreSQL 连接串
DATABASE_URL=${DATABASE_URL}
# 连接池大小(常驻连接数)
DATABASE_POOL_SIZE=20
# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数)
DATABASE_MAX_OVERFLOW=10
# 获取连接超时时间(秒)
DATABASE_POOL_TIMEOUT=30
# 连接回收时间(秒),防止数据库端主动断开导致的死连接
DATABASE_POOL_RECYCLE=3600
# 不使用内存数据库
USE_IN_MEMORY_DB=false
# ==================== Redis 配置 ====================
# Redis 连接 URL(格式:redis://[:password@]host:port/db
# ${REDIS_URL} — 替换为实际的 Production Redis 连接串
REDIS_URL=${REDIS_URL}
# 启用 Redis Session 存储(多实例部署必须开启)
ENABLE_REDIS_SESSIONS=true
# ==================== Celery 任务队列 ====================
# Celery Broker(任务分发),使用 Redis db0
CELERY_BROKER_URL=${CELERY_BROKER_URL}
# Celery Result Backend(任务结果存储),使用 Redis db1
CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# Worker 并发数(同时执行的任务数)
WORKER_CONCURRENCY=4
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
WORKER_MAX_TASKS_PER_CHILD=1000
# ==================== JWT 认证配置 ====================
# JWT 签名密钥 — 必须设置为强随机字符串(至少32字符)
# ${JWT_SECRET_KEY} — 替换为实际的随机密钥
JWT_SECRET_KEY=${JWT_SECRET_KEY}
# JWT 签名算法
JWT_ALGORITHM=HS256
# Access Token 过期时间(分钟)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
# Refresh Token 过期时间(天)
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ==================== 邮件配置 ====================
# 邮件功能尚未上线,暂时关闭
ENABLE_EMAIL_DELIVERY=false
# SMTP 服务器地址
SMTP_HOST=
# SMTP 端口
SMTP_PORT=587
# SMTP 用户名(邮件功能上线后配置)
SMTP_USER=
# SMTP 密码(邮件功能上线后配置)
SMTP_PASSWORD=
# 发件人邮箱(邮件功能上线后配置)
SMTP_FROM_EMAIL=
# 发件人显示名称
SMTP_FROM_NAME=小虾 SaaS
# 启用 TLS
SMTP_USE_TLS=true
# ==================== 阿里云 OSS 配置 ====================
# OSS 区域 endpoint
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
# OSS Access Key ID
# ${OSS_ACCESS_KEY_ID} — 替换为实际的 OSS Access Key ID
OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID}
# OSS Access Key Secret
# ${OSS_ACCESS_KEY_SECRET} — 替换为实际的 OSS Access Key Secret
OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET}
# OSS Bucket 名称
OSS_BUCKET_NAME=xiaoxia-autocut
# 直传最大文件大小(MB
OSS_DIRECT_UPLOAD_MAX_MB=2000
# 直传签名有效期(秒)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== CORS 配置 ====================
# 允许跨域的前端域名列表,逗号分隔
CORS_ORIGINS_RAW=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
# ==================== 生成文件路径 ====================
# 容器内生成文件目录(固定值,勿改)
GENERATED_FILES_DIR=/app/generated
# 生成文件 URL 前缀
GENERATED_FILES_URL_PREFIX=/generated-files
# 主机上生成文件目录(供 Docker volume bind mount 使用)
GENERATED_FILES_HOST_DIR=/var/lib/xiaoxia-saas-production/generated
# ==================== 渲染引擎配置 ====================
# 渲染引擎选择:legacy(旧引擎,稳定)/ unified(新架构)
RENDER_ENGINE=legacy
# ==================== CosyVoice 语音合成 ====================
# 阿里云百灵语音合成服务 API Key
# ${COSYVOICE_API_KEY} — 替换为实际的 CosyVoice API Key
COSYVOICE_API_KEY=${COSYVOICE_API_KEY}
# API 基础 URL
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
# 模型选择:cosyvoice-v3-flash(推荐)/ cosyvoice-v3-plus
COSYVOICE_MODEL=cosyvoice-v3-flash
# 音色:v3 系列系统音色带 _v3 后缀
COSYVOICE_VOICE=longxiaoxia_v3
# 采样率
COSYVOICE_SAMPLE_RATE=22050
# 输出格式
COSYVOICE_FORMAT=wav
# 音色克隆模型名(固定值)
COSYVOICE_CLONE_MODEL=voice-enrollment
# DashScope 通用 API Key(与 CosyVoice 共用)
DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
# ==================== MediaKit 视频理解(火山引擎)====================
MEDIAKIT_API_KEY=${MEDIAKIT_API_KEY}
MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
MEDIAKIT_TIMEOUT=60
# ==================== 监控(可选)====================
# Sentry DSN(取消注释并填入实际值以启用错误追踪)
# SENTRY_DSN=${SENTRY_DSN}
-233
View File
@@ -1,233 +0,0 @@
# ============================================================
# 小虾 SaaS — Staging 环境配置模板
# ============================================================
# 使用方式:复制为 /var/lib/xiaoxia-saas-staging/.env 并填入实际密钥
# 敏感值标记为 ${PLACEHOLDER},部署前必须替换为真实值
# ============================================================
# ==================== 应用基本配置 ====================
# 应用名称
APP_NAME=xiaoxia-saas
# 环境标识
APP_ENV=staging
# Staging 开启 Debug 模式便于排查问题
DEBUG=true
# 应用基础 URL(前端页面地址)
APP_BASE_URL=https://staging.xiaoxiajianji.com
# 对外公开的 API 基础 URL(用于生成回调链接等)
PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com
# API 服务监听地址
API_HOST=0.0.0.0
# API 服务监听端口
API_PORT=8000
# 生产/预发布环境关闭自动建表,使用 alembic migration
AUTO_CREATE_SCHEMA=false
# ==================== 数据库配置 ====================
# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname
# ${DATABASE_URL} — 替换为实际的 Staging PostgreSQL 连接串
DATABASE_URL=${DATABASE_URL}
# 连接池大小(常驻连接数)
DATABASE_POOL_SIZE=20
# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数)
DATABASE_MAX_OVERFLOW=10
# 获取连接超时时间(秒)
DATABASE_POOL_TIMEOUT=30
# 连接回收时间(秒),防止数据库端主动断开导致的死连接
DATABASE_POOL_RECYCLE=3600
# 不使用内存数据库
USE_IN_MEMORY_DB=false
# ==================== Redis 配置 ====================
# Redis 连接 URL(格式:redis://[:password@]host:port/db
# ${REDIS_URL} — 替换为实际的 Staging Redis 连接串
REDIS_URL=${REDIS_URL}
# 启用 Redis Session 存储(多实例部署必须开启)
ENABLE_REDIS_SESSIONS=true
# ==================== Celery 任务队列 ====================
# Celery Broker(任务分发),使用 Redis db0
CELERY_BROKER_URL=${CELERY_BROKER_URL}
# Celery Result Backend(任务结果存储),使用 Redis db1
CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# Worker 并发数(同时执行的任务数)
WORKER_CONCURRENCY=1
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
WORKER_MAX_TASKS_PER_CHILD=1000
# ==================== JWT 认证配置 ====================
# JWT 签名密钥 — 必须设置为强随机字符串(至少32字符)
# ${JWT_SECRET_KEY} — 替换为实际的随机密钥
JWT_SECRET_KEY=${JWT_SECRET_KEY}
# JWT 签名算法
JWT_ALGORITHM=HS256
# Access Token 过期时间(分钟)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Refresh Token 过期时间(天)
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ==================== 邮件配置 ====================
# 邮件功能尚未上线,暂时关闭
ENABLE_EMAIL_DELIVERY=false
# SMTP 服务器地址
SMTP_HOST=smtp.gmail.com
# SMTP 端口
SMTP_PORT=587
# SMTP 用户名(邮件功能上线后配置)
SMTP_USER=
# SMTP 密码(邮件功能上线后配置)
SMTP_PASSWORD=
# 发件人邮箱(邮件功能上线后配置)
SMTP_FROM_EMAIL=
# 发件人显示名称
SMTP_FROM_NAME=小虾 SaaS
# 启用 TLS
SMTP_USE_TLS=true
# ==================== 阿里云 OSS 配置 ====================
# OSS 区域 endpoint
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
# OSS Access Key ID
# ${OSS_ACCESS_KEY_ID} — 替换为实际的 OSS Access Key ID
OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID}
# OSS Access Key Secret
# ${OSS_ACCESS_KEY_SECRET} — 替换为实际的 OSS Access Key Secret
OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET}
# OSS Bucket 名称
OSS_BUCKET_NAME=xiaoxia-autocut
# 直传最大文件大小(MB
OSS_DIRECT_UPLOAD_MAX_MB=2000
# 直传签名有效期(秒)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== MinIO 配置(Staging 独有)====================
# Staging 环境使用 MinIO 替代 OSS 进行文件存储测试
# MinIO 服务 Endpoint
# ${MINIO_ENDPOINT} — 替换为实际的 MinIO 地址
MINIO_ENDPOINT=${MINIO_ENDPOINT}
# MinIO Access Key
# ${MINIO_ACCESS_KEY} — 替换为实际的 MinIO Access Key
MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
# MinIO Secret Key
# ${MINIO_SECRET_KEY} — 替换为实际的 MinIO Secret Key
MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
# MinIO Bucket 名称
MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
# 是否使用 SSL 连接 MinIO
MINIO_USE_SSL=false
# ==================== CORS 配置 ====================
# 允许跨域的前端域名列表,逗号分隔
CORS_ORIGINS_RAW=https://staging.xiaoxiajianji.com,https://staging-api.xiaoxiajianji.com
# ==================== 生成文件路径 ====================
# 容器内生成文件目录(固定值,勿改)
GENERATED_FILES_DIR=/app/generated
# 生成文件 URL 前缀
GENERATED_FILES_URL_PREFIX=/generated-files
# 主机上生成文件目录(供 Docker volume bind mount 使用)
GENERATED_FILES_HOST_DIR=/var/lib/xiaoxia-saas-staging/generated
# ==================== 渲染引擎配置 ====================
# 渲染引擎选择:legacy(旧引擎,稳定)/ unified(新架构)
RENDER_ENGINE=legacy
# ==================== CosyVoice 语音合成 ====================
# 阿里云百灵语音合成服务 API Key
# ${COSYVOICE_API_KEY} — 替换为实际的 CosyVoice API Key
COSYVOICE_API_KEY=${COSYVOICE_API_KEY}
# API 基础 URL
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
# 模型选择:cosyvoice-v3-flash(推荐)/ cosyvoice-v3-plus
COSYVOICE_MODEL=cosyvoice-v3-flash
# 音色:v3 系列系统音色带 _v3 后缀
COSYVOICE_VOICE=longxiaoxia_v3
# 采样率
COSYVOICE_SAMPLE_RATE=22050
# 输出格式
COSYVOICE_FORMAT=wav
# 音色克隆模型名(固定值)
COSYVOICE_CLONE_MODEL=voice-enrollment
# DashScope 通用 API Key(与 CosyVoice 共用)
DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
# ==================== MediaKit 视频理解(火山引擎)====================
MEDIAKIT_API_KEY=${MEDIAKIT_API_KEY}
MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
MEDIAKIT_TIMEOUT=60
-51
View File
@@ -1,51 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
client_max_body_size 800m;
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
location / {
try_files $uri /index.html;
}
# API proxy — Production 环境代理到 production API 容器
resolver 127.0.0.11 valid=10s;
resolver_timeout 5s;
location /api/ {
proxy_pass http://xiaoxia-api-production:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
# Generated files — 通过 alias 映射容器内 /app/generated/ 目录
location /generated-files/ {
alias /app/generated/;
}
# Assets with legacy fallback — 部署期间兼容旧版缓存的 hash 文件名
# 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的)
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri /assets-legacy$uri =404;
}
# 静态资源长缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
-50
View File
@@ -1,50 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
client_max_body_size 800m;
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# SPA fallback
location / {
try_files $uri /index.html;
}
# API proxy — Staging 环境代理到 staging API 容器
resolver 127.0.0.11 valid=10s;
resolver_timeout 5s;
location /api/ {
proxy_pass http://xiaoxia-api-staging:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
# Generated files — 通过 alias 映射容器内 /app/generated/ 目录
location /generated-files/ {
alias /app/generated/;
}
# 静态资源长缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+3 -8
View File
@@ -175,14 +175,9 @@ services:
- xiaoxia-net
# =========================================
# Nginx 配置运行时覆盖
# 确保容器使用正确环境的 nginx 配置,即使镜像构建时使用了默认配置
# 注意: 只覆盖 /etc/nginx/conf.d/default.conf,不挂载 /usr/share/nginx/html
# 重要: 生产环境不要添加任何 volume 挂载到 /usr/share/nginx/html
# 这会导致静态文件被覆盖,返回 403 错误
# =========================================
environment:
- NGINX_ENV=${ENV:-staging}
volumes:
- ./nginx-${ENV:-staging}.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"]
@@ -213,7 +208,7 @@ volumes:
# 重要: 确保主机目录存在且有正确权限
# Staging: /var/lib/xiaoxia-saas-staging/generated
# Production: /var/lib/xiaoxia-saas-production/generated
device: ${GENERATED_FILES_HOST_DIR:?GENERATED_FILES_HOST_DIR must be set in .env}
device: ${GENERATED_FILES_HOST_DIR:-/var/lib/xiaoxia-saas-staging/generated}
# ===========================================
# 网络配置
@@ -127,13 +127,6 @@ class InMemoryAssetRepository:
items = [a for a in self._assets.values() if tag_set.issubset(set(a.tag_ids))]
return items[skip : skip + limit]
def find_by_storage_key(self, storage_key: str) -> Asset | None:
"""按 storage_key 查找素材。"""
for asset in self._assets.values():
if asset.storage_key == storage_key:
return asset
return None
def find_by_library_and_file_hash(
self,
library_id: str,
+2 -2
View File
@@ -99,8 +99,8 @@ class SessionStore(SessionStorePort):
session_id: str,
user_id: str,
refresh_token: str,
device_info: str = "",
ip_address: str = "",
device_info: str,
ip_address: str,
expires_in_seconds: int = 30 * 24 * 60 * 60, # 30 天
) -> bool:
"""
+2 -2
View File
@@ -96,7 +96,7 @@ class EmailService(EmailServicePort):
except Exception as e:
return False, str(e)
def send_verification_email( # type: ignore[override]
def send_verification_email(
self,
to_email: str,
username: str,
@@ -165,7 +165,7 @@ class EmailService(EmailServicePort):
return self.send_email(to_email, subject, html_body, text_body)
def send_password_reset_email( # type: ignore[override]
def send_password_reset_email(
self,
to_email: str,
username: str,
@@ -426,13 +426,6 @@ class SQLAlchemyAssetRepository:
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
return [self._to_domain(m) for m in models]
def find_by_storage_key(self, storage_key: str) -> Asset | None:
"""按 storage_key(对应 DB 中的 file_url)查找素材。"""
model = self.session.query(AssetModel).filter(AssetModel.file_url == storage_key).first()
if model is None:
return None
return self._to_domain(model)
def find_by_library_and_file_hash(
self,
library_id: str,
@@ -25,8 +25,6 @@ class SQLAlchemyDuplicationRecordRepository:
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
visual_similarity=record.visual_similarity,
match_count=record.match_count,
video_fingerprint=json.dumps(record.video_fingerprint) if record.video_fingerprint else None,
error_message=record.error_message,
created_at=record.created_at,
@@ -60,8 +58,6 @@ class SQLAlchemyDuplicationRecordRepository:
model.status = record.status
model.duplicate_rate = record.duplicate_rate
model.duplicate_count = record.duplicate_count
model.visual_similarity = record.visual_similarity
model.match_count = record.match_count
model.video_fingerprint = json.dumps(record.video_fingerprint) if record.video_fingerprint else None
model.error_message = record.error_message
model.updated_at = record.updated_at
@@ -125,8 +121,6 @@ class SQLAlchemyDuplicationRecordRepository:
status=model.status,
duplicate_rate=model.duplicate_rate,
duplicate_count=int(model.duplicate_count or 0),
visual_similarity=getattr(model, "visual_similarity", None),
match_count=getattr(model, "match_count", None),
video_fingerprint=json.loads(fp_raw) if fp_raw else None,
error_message=getattr(model, "error_message", ""),
segments=segments,
@@ -131,65 +131,3 @@ class SQLAlchemyEditPlanClipRepository:
created_at=model.created_at,
updated_at=model.updated_at,
)
def list_used_segments_by_user(
self,
user_id: str,
*,
limit_recent: int = 50,
) -> dict[str, list[tuple[float, float]]]:
"""查询用户已有视频中已使用的素材区间(跨视频避让).
JOIN edit_plans created_by_user_id 过滤只查 status='completed'
plan status='rendered' asset_id 非空的 clips plan
created_at DESC 取最近 limit_recent plan
Returns:
{asset_id: [(start_time, start_time + duration), ...]}
空结果返回空 dict
"""
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
if not user_id:
return {}
# 1. 查出最近 limit_recent 个已完成 plan 的 ID
recent_plan_ids = [
row[0]
for row in self.session.query(EditPlanModel.id)
.filter(
EditPlanModel.created_by_user_id == user_id,
EditPlanModel.status == "completed",
)
.order_by(EditPlanModel.created_at.desc())
.limit(limit_recent)
.all()
]
if not recent_plan_ids:
return {}
# 2. 查这些 plan 下已渲染、有素材的 clips
clips = (
self.session.query(
EditPlanClipModel.asset_id,
EditPlanClipModel.start_time,
EditPlanClipModel.duration,
)
.filter(
EditPlanClipModel.plan_id.in_(recent_plan_ids),
EditPlanClipModel.status == "rendered",
EditPlanClipModel.asset_id != "",
EditPlanClipModel.asset_id.isnot(None),
)
.all()
)
# 3. 聚合为 {asset_id: [(start, start+duration), ...]}
result: dict[str, list[tuple[float, float]]] = {}
for asset_id, start_time, duration in clips:
if asset_id not in result:
result[asset_id] = []
result[asset_id].append((start_time or 0.0, (start_time or 0.0) + (duration or 0.0)))
return result
@@ -31,8 +31,6 @@ class SQLAlchemyGeneratedVideoRepository:
is_duplicate=video.is_duplicate,
duplicate_of=video.duplicate_of,
duplicate_rate=video.duplicate_rate,
match_count=getattr(video, "match_count", None),
visual_similarity=getattr(video, "visual_similarity", None),
generated_at=video.generated_at,
created_at=video.created_at,
)
@@ -64,8 +62,6 @@ class SQLAlchemyGeneratedVideoRepository:
is_duplicate=getattr(model, "is_duplicate", False),
duplicate_of=getattr(model, "duplicate_of", None),
duplicate_rate=getattr(model, "duplicate_rate", None),
match_count=getattr(model, "match_count", None),
visual_similarity=getattr(model, "visual_similarity", None),
generated_at=model.generated_at,
created_at=model.created_at,
)
@@ -81,8 +77,6 @@ class SQLAlchemyGeneratedVideoRepository:
model.is_duplicate = video.is_duplicate
model.duplicate_of = video.duplicate_of
model.duplicate_rate = video.duplicate_rate
model.match_count = getattr(video, "match_count", None)
model.visual_similarity = getattr(video, "visual_similarity", None)
self.session.add(model)
self.session.commit()
return video
@@ -91,24 +85,6 @@ class SQLAlchemyGeneratedVideoRepository:
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
return [self._to_domain(model) for model in models]
def list_by_user(self, user_id: str, *, duration_min: float = 0, duration_max: float = 0) -> list[GeneratedVideo]:
"""按 user_id 查询用户所有项目的视频(跨项目查重)。
Args:
user_id: 用户 ID
duration_min: 时长下限0 表示不限
duration_max: 时长上限0 表示不限
"""
query = self.session.query(GeneratedVideoModel).filter(
GeneratedVideoModel.user_id == user_id,
)
if duration_min > 0:
query = query.filter(GeneratedVideoModel.duration >= duration_min)
if duration_max > 0:
query = query.filter(GeneratedVideoModel.duration <= duration_max)
models = query.all()
return [self._to_domain(model) for model in models]
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
models = (
self.session.query(GeneratedVideoModel)
@@ -232,8 +208,6 @@ class SQLAlchemyGeneratedVideoRepository:
is_duplicate=getattr(model, "is_duplicate", False),
duplicate_of=getattr(model, "duplicate_of", None),
duplicate_rate=getattr(model, "duplicate_rate", None),
match_count=getattr(model, "match_count", None),
visual_similarity=getattr(model, "visual_similarity", None),
generated_at=model.generated_at,
created_at=model.created_at,
)
@@ -289,7 +289,6 @@ class GenerationTaskModel(Base):
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(36), nullable=True, index=True)
edit_plan_id = Column(String(36), nullable=True, index=True)
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(36), nullable=False, default="", index=True)
video_title = Column(String(255), nullable=False, default="")
@@ -340,8 +339,6 @@ class GeneratedVideoModel(Base):
is_duplicate = Column(Boolean, nullable=False, default=False)
duplicate_of = Column(String(36), nullable=True)
duplicate_rate = Column(Float, nullable=True)
match_count = Column(Integer, nullable=True, default=0)
visual_similarity = Column(Float, nullable=True, default=0.0)
class TitleLibraryModel(Base):
@@ -417,9 +414,6 @@ class DuplicationRecordModel(Base):
status = Column(String(20), nullable=False, default="pending", index=True)
duplicate_rate = Column(Float, nullable=True)
duplicate_count = Column(Integer, nullable=False, default=0)
# #1661 手动查重:视觉相似度(0~1)/ 匹配视频数
visual_similarity = Column(Float, nullable=True)
match_count = Column(Integer, nullable=True)
video_fingerprint = Column(Text, nullable=True)
error_message = Column(Text, nullable=False, default="")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -503,7 +497,6 @@ class TemplateCategoryModel(Base):
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -625,20 +618,3 @@ class CoverTemplateModel(Base):
config = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class VideoFingerprintChunkModel(Base):
"""分片视频指纹 — 每个视频按时间分片存储 pHash + color_histogram."""
__tablename__ = "video_fingerprint_chunks"
id = Column(String(36), primary_key=True)
video_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), nullable=False, index=True)
user_id = Column(String(36), nullable=False, index=True, default="")
start_time_ms = Column(Integer, nullable=False)
end_time_ms = Column(Integer, nullable=False)
phash_binary = Column(String(16), nullable=False)
color_histogram = Column(JSON, nullable=False)
frame_count = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+1 -1
View File
@@ -51,7 +51,7 @@ def parse_titles_from_response(content: str) -> list[str]:
pass
# 尝试按行解析
titles = []
titles: list[str] = []
for line in content.strip().split("\n"):
line = line.strip()
if not line:
+1 -16
View File
@@ -63,9 +63,6 @@ class DuplicationRecord:
status: str = "pending" # pending / processing / completed / failed
duplicate_rate: float | None = None # 0-100
duplicate_count: int = 0
# #1661 手动查重:视觉相似度(归一化 0~1)/ 匹配视频数
visual_similarity: float | None = None
match_count: int | None = None
video_fingerprint: dict[str, Any] | None = None
error_message: str = ""
segments: list[DuplicateSegment] = field(default_factory=list)
@@ -101,23 +98,13 @@ class DuplicationRecord:
self.status = "processing"
self.updated_at = datetime.now(timezone.utc)
def mark_completed(
self,
duplicate_rate: float,
duplicate_count: int,
segments: list[DuplicateSegment],
*,
visual_similarity: float | None = None,
match_count: int | None = None,
) -> None:
def mark_completed(self, duplicate_rate: float, duplicate_count: int, segments: list[DuplicateSegment]) -> None:
if not 0 <= duplicate_rate <= 100:
raise ValueError("duplicate_rate must be between 0 and 100")
self.status = "completed"
self.duplicate_rate = duplicate_rate
self.duplicate_count = duplicate_count
self.segments = segments
self.visual_similarity = visual_similarity
self.match_count = match_count
self.updated_at = datetime.now(timezone.utc)
def mark_failed(self, error_message: str) -> None:
@@ -146,8 +133,6 @@ class DuplicationRecord:
self.status = "pending"
self.duplicate_rate = None
self.duplicate_count = 0
self.visual_similarity = None
self.match_count = None
self.error_message = ""
self.segments = []
self.video_fingerprint = None
-2
View File
@@ -27,8 +27,6 @@ class GeneratedVideo:
is_duplicate: bool = False
duplicate_of: str | None = None
duplicate_rate: float | None = None
match_count: int | None = None
visual_similarity: float | None = None
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+17 -218
View File
@@ -27,137 +27,6 @@ DEFAULT_INTRO_DURATION = 3.0
DEFAULT_OUTRO_DURATION = 3.0
# ── SceneChange 镜头段工具 ────────────────────────────────────────────────────
def build_scene_segments(
scene_changes: list[float],
asset_duration: float,
) -> list[tuple[float, float]]:
"""根据场景切换点构建镜头段列表.
Args:
scene_changes: 场景切换点时间戳列表已排序首位为 0.0
asset_duration: 素材总时长
Returns:
镜头段列表 [(start, end), ...]仅保留长度 >= 0.5s 的段
"""
segments: list[tuple[float, float]] = []
for i, ts in enumerate(scene_changes):
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
# 只保留有效长度的镜头段(至少 0.5 秒)
if end - ts >= 0.5:
segments.append((ts, end))
return segments
def pick_start_in_scene_segment(
seg_start: float,
seg_end: float,
clip_duration: float,
) -> float | None:
"""在镜头段内随机选取一个起始时间点.
确保 start + clip_duration <= seg_end
若镜头段长度不足以容纳片段返回 None
"""
available = seg_end - seg_start - clip_duration
if available < 0:
return None
max_start = seg_start + available
return random.uniform(seg_start, max_start)
def _segments_overlap(
start: float,
duration: float,
used: list[tuple[float, float]],
edge_gap: float = 0.0,
) -> bool:
"""候选区间 [start, start+duration] 是否与已用区间冲突(含边缘间隙扩边)。"""
end = start + duration
for used_start, used_end in used:
if start < used_end + edge_gap and end > used_start - edge_gap:
return True
return False
def pick_scene_aware_start(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float],
asset_scene_points: dict[str, list[float]] | None,
used_segments: dict[str, list[tuple[float, float]]],
*,
edge_gap: float = 0.0,
) -> float | None:
"""基于缓存的场景切换点,从随机镜头段中选取不冲突的起始时间.
流程
1. 读取 asset_scene_points 中该素材的场景切换点缓存 构建镜头段
2. random.shuffle 镜头段保证同一素材多次生成选不同镜头而非固定第N段
3. 依次尝试段内随机取点 越界检查 used_segments 冲突检查
4. 全部冲突/无缓存 返回 None由调用方回退 _calc_random_start_time
Args:
asset_id: 素材 ID
clip_duration: 片段时长
asset_durations: 素材 ID -> 总时长
asset_scene_points: 素材 ID -> 场景切换点列表metadata 缓存
used_segments: 素材 ID -> 已用区间列表冲突避让
edge_gap: 冲突判定的边缘间隙已用区间按 [s-gap, e+gap] 扩边
"""
asset_total = (asset_durations or {}).get(asset_id)
if not asset_total or asset_total <= 0:
return None
scene_points = (asset_scene_points or {}).get(asset_id)
if not scene_points:
return None
used = used_segments.get(asset_id, []) if used_segments else []
scene_segments = build_scene_segments(scene_points, asset_total)
if not scene_segments:
return None
random.shuffle(scene_segments)
for seg_start, seg_end in scene_segments:
candidate = pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
if candidate is None:
continue
# 越界检查(防御:场景点末尾段理论上不越界,metadata 脏数据兜底)
if candidate + clip_duration > asset_total:
continue
# 与已用区间冲突检查
if _segments_overlap(candidate, clip_duration, used, edge_gap):
continue
return candidate
return None
def extract_scene_points_from_metadata(metadata: object) -> list[float] | None:
"""从素材 metadata 中提取并校验场景切换点缓存.
合法缓存list 类型至少 2 个数值点单调非负否则返回 None按未缓存处理
"""
if not isinstance(metadata, dict):
return None
points = metadata.get("scene_change_points")
if not isinstance(points, list) or len(points) < 2:
return None
try:
cleaned = [float(p) for p in points]
except (TypeError, ValueError):
return None
if any(p < 0 for p in cleaned):
return None
cleaned = sorted(cleaned)
if cleaned[0] != 0.0:
cleaned.insert(0, 0.0)
return cleaned
# ── 素材分配 ────────────────────────────────────────────────────────────────
@@ -168,8 +37,6 @@ def distribute_assets(
*,
random_selection: bool = False,
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改).
@@ -179,17 +46,12 @@ def distribute_assets(
- VOICE_OVER: 素材main clips (B-roll)
- VOICE_PIP: 第1个background, 第2个corner_voice, 其余b_roll
start_time 选取素材 metadata 中有场景切换点缓存时优先从随机镜头段
取起点不同片段来自不同镜头无缓存或镜头段都冲突时回退随机起点
Args:
clips: 剪辑片段列表就地修改 asset_id
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式字符串
random_selection: 是否随机选择素材用于预览生成
asset_durations: 素材 ID -> 时长映射用于设置 start_time
asset_scene_points: 素材 ID -> 场景切换点列表metadata 缓存
external_used_segments: 跨视频已用区间来自其他视频的 clips注入到分配逻辑中避让
asset_durations: 素材 ID -> 时长映射用于设置随机 start_time
"""
if not asset_ids or not clips:
return
@@ -200,69 +62,30 @@ def distribute_assets(
random.shuffle(asset_ids)
if editing_mode == EditingMode.ONE_TAKE.value:
_distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
_distribute_one_take(clips, asset_ids, asset_durations)
elif editing_mode == EditingMode.PIP.value:
_distribute_pip(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
_distribute_pip(clips, asset_ids, asset_durations)
elif editing_mode == EditingMode.VOICE_OVER.value:
_distribute_voice_over(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
_distribute_voice_over(clips, asset_ids, asset_durations)
elif editing_mode == EditingMode.VOICE_PIP.value:
_distribute_voice_pip(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
_distribute_voice_pip(clips, asset_ids, asset_durations)
else:
# 未知模式,退化为 one_take
_distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
def _resolve_start_time(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float] | None,
used_segments: dict[str, list[tuple[float, float]]],
asset_scene_points: dict[str, list[float]] | None = None,
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
) -> float | None:
"""选取片段起点:场景缓存优先(随机镜头段),无缓存/全冲突回退随机起点.
场景路径与随机路径共享 used_segments 冲突避让场景路径返回 None
无缓存镜头段全冲突回退 _calc_random_start_time其受控复用逻辑
on_exhausted不受影响
"""
if asset_scene_points and asset_scene_points.get(asset_id):
scene_start = pick_scene_aware_start(
asset_id,
clip_duration,
asset_durations or {},
asset_scene_points,
used_segments,
)
if scene_start is not None:
return scene_start
return _calc_random_start_time(
asset_id,
clip_duration,
asset_durations,
used_segments,
on_exhausted=on_exhausted,
)
_distribute_one_take(clips, asset_ids, asset_durations)
def _distribute_one_take(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips."""
used_segments: dict[str, list[tuple[float, float]]] = (
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
)
used_segments: dict[str, list[tuple[float, float]]] = {}
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):
asset_id = asset_ids[i]
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -275,20 +98,14 @@ def _distribute_pip(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips."""
used_segments: dict[str, list[tuple[float, float]]] = (
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
)
used_segments: dict[str, list[tuple[float, float]]] = {}
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
asset_id = asset_ids[0]
start_time = _resolve_start_time(
asset_id, main_clips[0].duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations, used_segments)
main_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -302,9 +119,7 @@ def _distribute_pip(
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -317,20 +132,14 @@ def _distribute_voice_over(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)."""
used_segments: dict[str, list[tuple[float, float]]] = (
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
)
used_segments: dict[str, list[tuple[float, float]]] = {}
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):
asset_id = asset_ids[i]
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -343,13 +152,9 @@ def _distribute_voice_pip(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll."""
used_segments: dict[str, list[tuple[float, float]]] = (
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
)
used_segments: dict[str, list[tuple[float, float]]] = {}
bg_clips = [c for c in clips if c.clip_type == "background"]
voice_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"]
@@ -359,9 +164,7 @@ def _distribute_voice_pip(
# 第1个 → background
if idx < len(asset_ids) and bg_clips:
asset_id = asset_ids[idx]
start_time = _resolve_start_time(
asset_id, bg_clips[0].duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations, used_segments)
bg_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -373,9 +176,7 @@ def _distribute_voice_pip(
# 第2个 → corner_voice
if idx < len(asset_ids) and voice_clips:
asset_id = asset_ids[idx]
start_time = _resolve_start_time(
asset_id, voice_clips[0].duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations, used_segments)
voice_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -389,9 +190,7 @@ def _distribute_voice_pip(
for i, clip in enumerate(broll_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
-7
View File
@@ -14,13 +14,6 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
# 素材选取排序时注入的随机噪声上限(分)。
# score_asset 综合得分范围为 0-100,噪声 0~20 意味着:
# - 素材间得分差距 > 20 分时,排名不受影响(质量差异显著的素材保持稳定优先级)
# - 得分接近(差距 <= 20 分)的素材排名会随机浮动,使每次生成选出的素材组合不同,
# 从素材组合层面降低成片重复率;排名靠后的低分素材也有机会入选。
SCORE_RANDOM_NOISE_MAX = 20.0
@dataclass
class SmartMatchResult:
+2 -5
View File
@@ -150,11 +150,8 @@ def build_xfade_filter_chain(
else:
first_input_dur = cumulative - total_transition
# 正确的 offset 计算offset 应相对于累积输出时长
# offset = 累积输出中,转场开始的时间点
# = first_input_dur - transition_duration
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
offset = max(0.0, first_input_dur - transition_duration)
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
-5
View File
@@ -112,11 +112,6 @@ class AssetRepository(ABC):
"""查找包含所有指定标签的素材。"""
pass
@abstractmethod
def find_by_storage_key(self, storage_key: str) -> Asset | None:
"""按 storage_key 查找素材(用于异步处理时更新已创建的记录)。"""
pass
@abstractmethod
def find_by_library_and_file_hash(
self,
-64
View File
@@ -119,70 +119,6 @@ class MediaKitClient:
return None
def detect_scene_changes(
self,
video_url: str,
max_frames: int = 20,
poll_interval: float = 2.0,
max_poll_attempts: int = 30,
) -> Optional[List[float]]:
"""检测视频场景切换点,返回时间戳列表.
降级策略
1. 先尝试 SceneChange 策略
2. SceneChange 失败OOM等 退回 TimeInterval5秒间隔
3. MediaKit 不可用 返回 None
Returns:
场景切换点时间戳列表 [0.0, 3.2, 7.8, 12.5]
失败返回 None
"""
if not self.is_available:
logger.warning("MediaKit 未配置,跳过场景检测")
return None
# 策略1:尝试 SceneChange
frames = self.extract_frames(
video_url=video_url,
strategy="SceneChange",
max_frames=max_frames,
poll_interval=poll_interval,
max_poll_attempts=max_poll_attempts,
)
# 策略2SceneChange 失败 → 退回 TimeInterval5秒间隔)
if frames is None:
logger.info("SceneChange 策略失败,降级为 TimeInterval5秒间隔)")
# 估算帧数:假设视频最长60秒,每5秒一帧
ti_max_frames = max(max_frames, 12)
frames = self.extract_frames(
video_url=video_url,
strategy="TimeInterval",
max_frames=ti_max_frames,
poll_interval=poll_interval,
max_poll_attempts=max_poll_attempts,
)
if frames is None:
return None
# 从帧列表中提取 timestamp,排序
timestamps = sorted({float(f.get("timestamp", 0.0)) for f in frames if "timestamp" in f})
if not timestamps:
return None
# 始终在列表开头加 0.0(素材起始点)
if timestamps[0] != 0.0:
timestamps.insert(0, 0.0)
logger.info(
"场景检测完成: video_url=%s scene_changes=%s",
video_url[:80],
timestamps,
)
return timestamps
def _submit_extract_task(
self,
video_url: str,
+1 -1
View File
@@ -100,7 +100,7 @@ def _check_ssrf_domain(hostname: str) -> None:
raise UrlSecurityError(f"域名解析失败: {hostname}")
for info in infos:
ip_str = str(info[4][0])
ip_str = info[4][0]
try:
_check_ssrf_ip_base(ip_str)
except ValueError:
+1 -3
View File
@@ -24,10 +24,8 @@ celery==5.4.0
# 对象存储
oss2==2.18.4
# HTTP 客户端pin 间接依赖防止版本漂移)
# HTTP 客户端
httpx==0.27.2
httpcore==1.0.7
h2==4.1.0
# Prometheus monitoring
prometheus-client==0.21.1
-1
View File
@@ -15,6 +15,5 @@ pytest-xdist==3.6.1
diff-cover==8.0.3
# 资产质量评分依赖(与 requirements-worker.txt 保持一致)
numpy==1.26.4
scipy==1.13.1
Pillow==10.4.0
+9 -24
View File
@@ -32,9 +32,9 @@ CONTEXTS=(
echo "检查CI Gate统一门禁"
echo
# 等待30秒后开始轮询,最多10分钟
echo "等待30秒让CI启动..."
sleep 30
# 等待60秒,给CI启动写status的时间
echo "等待60秒让CI启动..."
sleep 60
# 405计数器(单次运行内重试)
MERGE_405_COUNT=0
@@ -72,9 +72,9 @@ check_and_merge() {
# CI未全绿(pending中)→ 退出,等下次触发
if [ "$ALL_SUCCESS" != "true" ]; then
echo
echo "⏳ CI尚未全绿(仍有pending),等待重试..."
echo " 当前第${attempt}次轮询,最多${MAX_ATTEMPTS}次)"
return 1
echo "⏳ CI尚未全绿(仍有pending),退出等待下次触发"
echo " pr-auto-scan每5分钟扫描一次,CI通过后会自动合并)"
exit 0
fi
# CI全绿 → 合并
@@ -136,28 +136,13 @@ check_and_merge() {
fi
}
# 轮询等待CI就绪+审批完成,最多10分钟(60次x10秒
MAX_ATTEMPTS=60
for attempt in $(seq 1 $MAX_ATTEMPTS); do
# 最多重试3次(用于405重试,非CI轮询
for i in 1 2 3; do
if check_and_merge; then
exit 0
fi
# 检查PR是否还open(可能已被手动合并或关闭)
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))" 2>/dev/null || echo "?")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需继续等待"
exit 0
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
sleep 10
fi
done
echo
echo "⏰ 等待10分钟后仍未满足合并条件,退出。pr-auto-scan定时扫描会继续重试。"
echo "本次检查未满足合并条件,退出。pr-auto-scan每5分钟会继续扫描。"
exit 0
-24
View File
@@ -4,7 +4,6 @@
# - docker-container driver, host 网络
# - 层缓存保存在 buildkit 容器及其 _state 命名卷中,job 结束不清理
# - 宿主机 ci-docker-cleanup.sh 已豁免该 builder
# - 每次执行自动同步宿主机 docker config 到 BuildKit 容器(确保 registry 认证)
# 用法: bash scripts/ci/ensure_persistent_builder.sh
set -eu
@@ -35,26 +34,3 @@ docker buildx use "$BUILDER"
docker buildx inspect "$BUILDER" --bootstrap
echo "✅ builder ready"
docker buildx ls | head -5
# === 同步宿主机 docker config 到 BuildKit 容器(确保 registry 认证) ===
# BuildKit 容器名遵循 docker buildx 命名规则: buildx_buildkit_<builder-name>_0
BUILDKIT_CONTAINER="buildx_buildkit_${BUILDER}_0"
if docker inspect "$BUILDKIT_CONTAINER" >/dev/null 2>&1; then
# 宿主机 docker config 路径
HOST_DOCKER_CONFIG="/root/.docker/config.json"
if [ -f "$HOST_DOCKER_CONFIG" ]; then
echo "=== 同步 docker config 到 BuildKit 容器 ==="
# 确保容器内 .docker 目录存在
docker exec "$BUILDKIT_CONTAINER" mkdir -p /root/.docker
# 拷贝 config.json
docker cp "$HOST_DOCKER_CONFIG" "$BUILDKIT_CONTAINER:/root/.docker/config.json"
echo "✅ docker config 已同步到 BuildKit 容器"
else
echo "⚠️ 宿主机 docker config 不存在: $HOST_DOCKER_CONFIG(跳过同步)"
fi
else
echo "⚠️ BuildKit 容器不存在: $BUILDKIT_CONTAINER(跳过 config 同步)"
fi
+47 -22
View File
@@ -52,45 +52,70 @@ bash scripts/ci/step_install_ffmpeg.sh
# 需要用宿主机IP访问映射端口
# 检测策略:host.docker.internal -> docker0桥接IP -> 容器IP直连 -> 默认网关 -> 127.0.0.1
detect_docker_host() {
# 目标:找到宿主机IP(DooD模式下CI容器访问宿主机上其他容器用)
# 不依赖特定端口TCP探测,直接用网络拓扑信息
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
# 候选IP列表
local candidates=()
# 1. host.docker.internalrunner配置了--add-host时可用)
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
echo "host.docker.internal"
return 0
candidates+=("host.docker.internal")
fi
# 2. 默认网关(Docker bridge模式下网关即宿主机)
# 2. docker0 桥接网关 (172.17.0.1)
candidates+=("172.17.0.1")
# 3. 默认网关(容器网络的网关即宿主机)
local gw=""
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
echo "$gw"
return 0
candidates+=("$gw")
fi
# 3. docker0 桥接网关
if [ -n "$(ip addr show docker0 2>/dev/null)" ]; then
echo "172.17.0.1"
return 0
# 4. 宿主机可能的IP:容器同网段的.1或.254
local my_ip=""
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$my_ip" ]; then
# 尝试同网段的常见宿主机IP
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
candidates+=("${subnet}.1")
candidates+=("${subnet}.254")
fi
# 4. 通过 git server hostname 反查(runner 配置了 ExtraHosts host-gateway
local git_host_ip=""
git_host_ip=$(python3 -c "import socket; print(socket.gethostbyname('git.xiaoxiajianji.com'))" 2>/dev/null || true)
if [ -n "$git_host_ip" ] && [ "$git_host_ip" != "127.0.0.1" ]; then
echo "$git_host_ip"
return 0
fi
# 5. 127.0.0.1 最后尝试
candidates+=("127.0.0.1")
# 5. 最终 fallback
# 测试每个候选IP
for candidate in "${candidates[@]}"; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
try:
s.connect(('$candidate', $test_port))
s.close()
print('ok')
except:
pass
" 2>/dev/null | grep -q ok; then
echo "$candidate"
return 0
fi
done
# 都失败则返回127.0.0.1
echo "127.0.0.1"
return 0
return 1
}
# 获取宿主机IP(先尝试用共享PG端口5433测试,再回退到其他端口)
if [ -S /var/run/docker.sock ]; then
DOCKER_HOST_IP=$(detect_docker_host)
# 先用共享PG端口5433探测
DOCKER_HOST_IP=$(detect_docker_host "${CI_SHARED_PG_PORT}")
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
# 如果共享PG端口探测失败,说明不在DooD或共享PG不可用,再试其他端口
DOCKER_HOST_IP=$(detect_docker_host 22)
fi
echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $DOCKER_HOST_IP"
else
DOCKER_HOST_IP="127.0.0.1"
@@ -202,7 +227,7 @@ else
postgres:16
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
# 等待容器健康
for i in $(seq 1 30); do
+47 -106
View File
@@ -1,136 +1,77 @@
#!/bin/bash
# CI Unit Tests Job 主脚本
# 包含:依赖缓存、增量测试选择、覆盖率测试、diff覆盖率门禁
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
set -eu
JOB_NAME="${1:-Unit Tests}"
echo "=== CI Unit Tests 开始 ==="
# --- 依赖缓存检查 ---
# 如果 requirements 文件未变化且依赖已安装,跳过 pip install(持久 runner 优化)
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash"
CURRENT_REQ_HASH=""
if [ -f requirements-base.txt ] && [ -f requirements.txt ] && [ -f requirements-dev.txt ]; then
CURRENT_REQ_HASH=$(cat requirements-base.txt requirements.txt requirements-dev.txt | md5sum | cut -d' ' -f1)
fi
SKIP_PIP_INSTALL=false
if [ -n "$CURRENT_REQ_HASH" ] && [ -f "$REQ_HASH_FILE" ]; then
CACHED_HASH=$(cat "$REQ_HASH_FILE")
if [ "$CACHED_HASH" = "$CURRENT_REQ_HASH" ]; then
# 验证关键包是否还在
if python3 -c "import pytest; import celery" 2>/dev/null; then
echo "✅ 依赖无变化 (hash=$CURRENT_REQ_HASH),跳过 pip install"
SKIP_PIP_INSTALL=true
else
echo "⚠️ 依赖 hash 匹配但关键包缺失,重新安装"
fi
fi
fi
# --- 安装依赖 ---
if [ "$SKIP_PIP_INSTALL" = "false" ]; then
echo ""
echo "=== 安装 Python 依赖 ==="
# pip install 带重试(网络不稳定时自动重试),合并为一次调用减少开销
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt -r requirements.txt -r requirements-dev.txt && break
echo "pip install 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
# 保存 hash 标记
if [ -n "$CURRENT_REQ_HASH" ]; then
echo "$CURRENT_REQ_HASH" > "$REQ_HASH_FILE"
fi
fi
echo ""
echo "=== 安装 Python 依赖 ==="
# pip install 带重试(网络不稳定时自动重试)
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
pytest --version
# 双保险:确保numpy已安装
echo "=== 验证 numpy 安装 ==="
SKIP_NUMPY_TESTS=0
if python3 -c "import numpy; assert numpy.__version__ == '1.26.4'" 2>/dev/null; then
echo " numpy 1.26.4 已就绪(缓存命中)"
else
echo "需要安装 numpy 1.26.4..."
python3 -m pip install numpy==1.26.4 || {
echo "❌ numpy 首次安装失败,尝试不使用缓存重新安装..."
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
SKIP_NUMPY_TESTS=1
}
python3 -m pip install numpy==1.26.4 || {
echo " numpy 首次安装失败,尝试不使用缓存重新安装..."
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
SKIP_NUMPY_TESTS=1
}
fi
}
if [ "$SKIP_NUMPY_TESTS" = "0" ]; then
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 就绪')" || {
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 安装成功')" || {
echo "⚠️ numpy 导入失败,跳过需要 numpy 的测试"
SKIP_NUMPY_TESTS=1
}
fi
# --- 增量测试选择(PR + push 均支持 ---
# --- 增量测试选择(PR ---
UNIT_TEST_MODE="full"
SELECTED_TEST_FILES="tests/unit"
IS_PULL_REQUEST=false
IS_PUSH=false
[ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && IS_PULL_REQUEST=true
[ "${GITHUB_EVENT_NAME:-}" = "push" ] && IS_PUSH=true
if ($IS_PULL_REQUEST || $IS_PUSH) && [ -n "${GITHUB_TOKEN:-}" ]; then
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
echo ""
echo "=== 增量测试选择 ==="
CHANGED_FILES=""
if $IS_PULL_REQUEST; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
elif $IS_PUSH && [ -n "${GITHUB_SHA:-}" ]; then
# Push 事件:通过 GitHub API 获取本次 push 改动的文件
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}"
RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3.diff" "$API_URL" 2>/dev/null || echo "")
if [ -n "$RESPONSE" ]; then
CHANGED_FILES=$(echo "$RESPONSE" | grep '^diff --git' | sed 's|diff --git a/\(.*\) b/.*|\1|' || echo "")
fi
# 备用方案:获取 previous commit SHA 再查 API
if [ -z "$CHANGED_FILES" ]; then
PREV_SHA=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits?sha=${GITHUB_SHA}&per_page=2" \
| python3 -c "import sys,json; commits=json.load(sys.stdin); print(commits[1]['sha'] if len(commits)>1 else '')" 2>/dev/null || echo "")
if [ -n "$PREV_SHA" ]; then
COMPARE_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/compare/${PREV_SHA}...${GITHUB_SHA}"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$COMPARE_URL" \
| python3 -c "import sys,json; data=json.load(sys.stdin); [print(f['filename']) for f in data.get('files',[]) if f['status'] != 'removed']" 2>/dev/null || echo "")
fi
fi
fi
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
if [ -n "$CHANGED_FILES" ]; then
set +e
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
set -e
if [ $SELECT_EXIT -eq 0 ]; then
UNIT_TEST_MODE="incremental"
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
SELECTED_TEST_FILES="$TEST_FILES"
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "全量模式(增量选择失败)"
fi
set +e
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
set -e
if [ $SELECT_EXIT -eq 0 ]; then
UNIT_TEST_MODE="incremental"
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
SELECTED_TEST_FILES="$TEST_FILES"
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "无法获取改动文件列表,使用全量模式"
echo "全量模式"
fi
fi
@@ -175,7 +116,7 @@ if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ];
PR_CODE_DIR="/tmp/pr-code-$$"
mkdir -p "$PR_CODE_DIR"
# 备份PR代码(含coverage.xmldiff-cover需要用到
# 备份PR代码(含coverage.xmldiff-cover需要用到
find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
rm -rf .git
git init > /dev/null 2>&1
+42 -23
View File
@@ -59,40 +59,56 @@ echo ""
# ============================================================
detect_docker_host() {
# 目标:找到宿主机IP(DooD模式下CI容器访问宿主机上其他容器用)
# 不依赖特定端口TCP探测,直接用网络拓扑信息
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
# 1. host.docker.internalrunner配置了--add-host时可用)
local candidates=()
# 1. host.docker.internal
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
echo "host.docker.internal"
return 0
candidates+=("host.docker.internal")
fi
# 2. 默认网关(Docker bridge模式下网关即宿主机)
# 2. docker0 桥接网关
candidates+=("172.17.0.1")
# 3. 默认网关
local gw=""
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
echo "$gw"
return 0
candidates+=("$gw")
fi
# 3. docker0 桥接网关
if [ -n "$(ip addr show docker0 2>/dev/null)" ]; then
echo "172.17.0.1"
return 0
# 4. 宿主机同网段的.1或.254
local my_ip=""
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$my_ip" ]; then
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
candidates+=("${subnet}.1")
candidates+=("${subnet}.254")
fi
# 4. 通过 git server hostname 反查(runner 配置了 ExtraHosts host-gateway
local git_host_ip=""
git_host_ip=$(python3 -c "import socket; print(socket.gethostbyname('git.xiaoxiajianji.com'))" 2>/dev/null || true)
if [ -n "$git_host_ip" ] && [ "$git_host_ip" != "127.0.0.1" ]; then
echo "$git_host_ip"
return 0
fi
# 5. 127.0.0.1 最后尝试
candidates+=("127.0.0.1")
for candidate in "${candidates[@]}"; do
if python3 -c "
import socket
s = socket.socket()
s.settimeout(2)
try:
s.connect(('$candidate', $test_port))
s.close()
print('ok')
except:
pass
" 2>/dev/null | grep -q ok; then
echo "$candidate"
return 0
fi
done
# 5. 最终 fallback
echo "127.0.0.1"
return 0
return 1
}
# 指数退避TCP连接检查
@@ -116,7 +132,10 @@ wait_tcp_ready() {
# 获取宿主机IP
if [ -S /var/run/docker.sock ]; then
DOCKER_HOST_IP=$(detect_docker_host)
DOCKER_HOST_IP=$(detect_docker_host "${CI_SHARED_PG_PORT}")
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
DOCKER_HOST_IP=$(detect_docker_host 22)
fi
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
else
DOCKER_HOST_IP="127.0.0.1"
@@ -194,7 +213,7 @@ else
postgres:16-alpine
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
# 等待容器健康
for i in $(seq 1 30); do
-49
View File
@@ -59,7 +59,6 @@ REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
NGINX_CONF_FILE="${NGINX_CONF_FILE:-/var/lib/xiaoxia-saas-production/nginx-production.conf}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
@@ -73,52 +72,6 @@ test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
# ── 写入 Production Nginx 配置 ──
echo "Writing production nginx config..."
cat > "$NGINX_CONF_FILE" << 'NGINX_EOF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
client_max_body_size 800m;
location / {
try_files $uri /index.html;
}
resolver 127.0.0.11 valid=10s;
resolver_timeout 5s;
location /api/ {
proxy_pass http://xiaoxia-api-production:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
location /generated-files/ {
alias /app/generated/;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
NGINX_EOF
echo "✅ Nginx config written: $NGINX_CONF_FILE"
echo "==========================================="
echo " Production 部署 - $IMAGE_TAG"
echo "==========================================="
@@ -235,7 +188,6 @@ rollback() {
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -433,7 +385,6 @@ docker run -d \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
+7 -7
View File
@@ -12,7 +12,7 @@
# 环境变量:
# PROD_API_URL - Production API 公网地址 (默认 https://api.xiaoxiajianji.com)
# PROD_WEB_URL - Production Web 公网地址 (默认 https://saas.xiaoxiajianji.com)
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 300)
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 180)
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
@@ -36,7 +36,7 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# 配置
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-300}"
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-180}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
@@ -44,7 +44,7 @@ PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
# 颜色
@@ -160,11 +160,11 @@ health_check() {
web_ok=true
fi
# 检查 API docs(生产环境禁用 /docs,404 表示 API 在正常响应,视为健康)
# 检查 API docs
if [ "$api_docs_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${PROD_API_URL}/docs" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "404" ]; then
log_info "✅ API Docs 检查通过HTTP $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
log_info "✅ API Docs 检查通过"
api_docs_ok=true
fi
fi
@@ -232,7 +232,7 @@ set -eu
IMAGE_TAG="$1"
REGISTRY_TOKEN="$2"
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
+2 -58
View File
@@ -46,7 +46,6 @@ REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
NGINX_CONF_FILE="${NGINX_CONF_FILE:-/var/lib/xiaoxia-saas-staging/nginx-staging.conf}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
@@ -56,63 +55,10 @@ if [ -z "$IMAGE_TAG" ]; then
exit 1
fi
# .env 文件由 CI 从模板 + Secrets 渲染后通过 SCP 上传到服务器
# 如果文件不存在,说明 CI 渲染步骤失败或未执行
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: $ENV_FILE 不存在。CI 应先在 render_env 步骤渲染并上传此文件"
exit 1
fi
echo "✅ .env file found: $ENV_FILE ($(wc -l < "$ENV_FILE") lines)"
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
# ── 写入 Staging Nginx 配置 ──
# 运行时覆盖 nginx 配置,确保 upstream 指向正确的 staging 网络
echo "Writing staging nginx config..."
cat > "$NGINX_CONF_FILE" << 'NGINX_EOF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
client_max_body_size 800m;
location / {
try_files $uri /index.html;
}
resolver 127.0.0.11 valid=10s;
resolver_timeout 5s;
location /api/ {
proxy_pass http://xiaoxia-api-staging:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
location /generated-files/ {
alias /app/generated/;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
NGINX_EOF
echo "✅ Nginx config written: $NGINX_CONF_FILE"
echo "==========================================="
echo " Staging 部署 - $IMAGE_TAG (并行优化版)"
echo "==========================================="
@@ -213,7 +159,6 @@ rollback() {
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -452,7 +397,7 @@ fi
echo "Stopping old containers..."
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
# Worker 需要更长时间(视频任务最长可能5分钟)
docker stop -t 120 xiaoxia-worker-staging 2>/dev/null || true
docker stop -t 300 xiaoxia-worker-staging 2>/dev/null || true
docker stop -t 30 xiaoxia-api-staging 2>/dev/null || true
docker stop -t 10 xiaoxia-web-staging 2>/dev/null || true
docker rm xiaoxia-worker-staging xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
@@ -516,7 +461,6 @@ docker run -d \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
+1 -1
View File
@@ -187,7 +187,7 @@ health_check() {
return 0
fi
sleep 3
sleep 5
done
# 超时了

Some files were not shown because too many files have changed in this diff Show More