Compare commits

...

9 Commits

Author SHA1 Message Date
xiaoxia 2c5600cfc9 feat: 素材选取评分注入随机噪声,降低成片查重率 (#1620)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m49s
CI/CD Pipeline / Validate - Style (push) Successful in 6m35s
CI/CD Pipeline / Integration Tests (push) Successful in 6m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 14m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-02 01:40:34 +08:00
saas-backend-agent c66e73e5b0 fix(test): 补齐噪声/转场补偿导致的 flaky 断言
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (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 / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m44s
AI Code Review / AI Code Review (pull_request) Successful in 5m21s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m11s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m50s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m24s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m13s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m55s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Validate - Security (push) Successful in 4m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m53s
CI/CD Pipeline / Unit Tests (push) Successful in 15m23s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- test_mediakit_smart_clips.test_assets_balanced_assignment:
  评分噪声下贪心均衡断言改为 zero-noise patch 内确定性验证
  (CI 失败:assert 1 == 2,与 test_editor_clips_random_start 同类)
- test_duration_within_segment_range:断言上界未计入 PR #1614
  转场补偿(2片段每clip +0.25s),uniform 接近上限时偶发 5.2>5.0;
  按补偿公式与舍入余量修正断言范围
2026-09-02 00:33:06 +08:00
saas-backend-agent 5f9538b5ac feat(ci): ci-base 镜像自动重建 workflow,requirements/Dockerfile 变更时自动构建推送
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m50s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 4m7s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m30s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m41s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 13m43s
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 / Build Production Web 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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-02 00:30:23 +08:00
xiaoxia 19b45cbee1 fix(deps): pin numpy==1.26.4 到 dev 依赖,与 worker 保持一致,消除 ci-base 镜像 numpy 2.x 漂移
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m19s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m32s
CI/CD Pipeline / Integration Tests (push) Successful in 8m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m49s
CI/CD Pipeline / Validate - Style (push) Successful in 8m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m33s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m26s
CI/CD Pipeline / Unit Tests (push) Failing after 17m19s
CI/CD Pipeline / Validate - Security (push) Successful in 18m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-02 00:23:15 +08:00
xiaoxia f51f1139bf fix(deps): pin httpcore==1.0.7 + h2==4.1.0 防止 ci-base 镜像依赖漂移
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
API Base Image Build / Build API Base Image (push) Failing after 3m47s
Worker Base Image Build / Build Worker Base Image (push) Failing after 4m21s
2026-09-02 00:23:13 +08:00
xiaoxia 9c03755318 fix(products): 查重率 0 时显示 0.0% 而非 - (#1619)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m26s
CI/CD Pipeline / Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m59s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m50s
CI/CD Pipeline / Validate - Security (push) Successful in 14m57s
CI/CD Pipeline / Unit Tests (push) Failing after 14m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-01 23:17:28 +08:00
CI Bot 25a98c33b9 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-01 13:32:22 +00:00
xiaoxia 8920bead38 fix(title): 预设卡片间距调整 gap 1px + 列宽 52px 固定 (#1617)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Validate - Style (push) Failing after 6m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m37s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m49s
CI/CD Pipeline / Unit Tests (push) Failing after 15m7s
CI/CD Pipeline / Validate - Security (push) Successful in 20m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(title): 预设卡片间距调整 gap 1px + 列宽 52px 固定 (#1617)
2026-09-01 21:23:41 +08:00
saas-backend-agent 827d8aafe5 feat: 素材分配顺序随机打乱 + MediaKit SceneChange智能选帧
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m26s
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
AI Code Review / AI Code Review (pull_request) Failing after 5m45s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web 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 (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m35s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m13s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m0s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 7m27s
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 / Validate - Python (mypy + alembic) (pull_request) Successful in 7m47s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 6m38s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 16m35s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 29m28s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web 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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 素材分配顺序随机打乱
   - segments 处理顺序通过 random.shuffle 随机化
   - clips_data 最终按原始 segment order 排序返回
   - 打乱的是分配顺序,不影响播放顺序

2. MediaKit SceneChange 智能选帧
   - mediakit_client.py 新增 detect_scene_changes 方法
   - SceneChange 优先,失败降级到 TimeInterval(5秒间隔)
   - 后台任务 _update_mediakit_recommendations_async 改用 SceneChange
   - 各片段优先从不同镜头段选取起始时间
   - analyze_videos 保留为 fallback
   - 所有降级路径:MediaKit不可用/SceneChange失败 → 保持随机start_time

3. 测试
   - 新增 tests/unit/test_scene_change_shuffle.py (12个测试用例)
   - 更新 test_editor_clips_random_start.py 适配 shuffle 逻辑
   - 全部 108 个相关测试通过
   - ruff check 通过
2026-09-01 21:16:51 +08:00
13 changed files with 904 additions and 139 deletions
+105
View File
@@ -0,0 +1,105 @@
name: CI Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements-dev.txt'
- 'infra/docker/ci.Dockerfile'
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - ci-base 镜像重建"
concurrency:
group: ci-base-image-build
cancel-in-progress: false
jobs:
build-ci-base:
name: Build CI Base Image
runs-on: runtime-builder
timeout-minutes: 60
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Gitea Registry
shell: sh
env:
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Build and push CI base image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
docker build --progress=plain \
-f infra/docker/ci.Dockerfile \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${VERSION_TAG}" \
.
echo "✅ Image built successfully"
echo "=== Pushing ${VERSION_TAG} ==="
docker push "${IMAGE}:${VERSION_TAG}"
echo "=== Pushing latest ==="
docker push "${IMAGE}:latest"
echo "✅ Pushed to Gitea Registry"
- name: Verify image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
echo "=== Verifying pinned deps in fresh image ==="
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
- name: Notify result
if: always()
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ "${{ job.status }}" = "success" ]; then
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
else
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
fi
- name: Cleanup
if: always()
shell: sh
run: |
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
docker rmi "${IMAGE}:latest" 2>/dev/null || true
echo "Cleanup done"
+226 -111
View File
@@ -48,7 +48,7 @@ from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
from packages.domain.smart_match import score_asset
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services
@@ -724,7 +724,12 @@ def create_clips_from_assets_editor(
else:
transition_compensation = 0.0
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 打乱 segments 的处理顺序(分配素材的顺序随机化),但最终 clips_data 按原始 order 排序
shuffled_indices = list(range(len(segments)))
random.shuffle(shuffled_indices)
for idx in shuffled_indices:
_seg_order, dur_min, dur_max = segments[idx]
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
@@ -739,14 +744,15 @@ def create_clips_from_assets_editor(
clip_duration = 0.0
start_time: float | None = None
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
asset_use_counts = {
aid: len(used_segments.get(aid, []))
for aid in asset_ids
}
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),
-(asset_smart_scores.get(aid, 0.0) + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX)),
asset_use_counts.get(aid, 0),
random.random(),
),
@@ -805,7 +811,7 @@ def create_clips_from_assets_editor(
clips_data.append(
{
"order": i,
"order": _seg_order,
"asset_id": asset_id,
"start_time": start_time,
"duration": clip_duration,
@@ -813,6 +819,9 @@ def create_clips_from_assets_editor(
}
)
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...
clips_data.sort(key=lambda c: c["order"])
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
@@ -839,15 +848,15 @@ def create_clips_from_assets_editor(
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
logger.warning(
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
plan_id, dup_rate,
plan_id,
dup_rate,
)
# 7. 素材耗尽提示
exhaustion_warning = None
if all_assets_exhausted and created_count < len(segments):
exhaustion_warning = (
"素材可切区间不足,部分片段使用了复用素材。"
"建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
"素材可切区间不足,部分片段使用了复用素材。" "建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
)
# 8. 立即返回响应
@@ -860,11 +869,58 @@ def create_clips_from_assets_editor(
)
def _build_scene_segments(
scene_changes: list[float],
asset_duration: float,
) -> list[tuple[float, float]]:
"""根据场景切换点构建镜头段列表.
Args:
scene_changes: 场景切换点时间戳列表(已排序,首位为 0.0)
asset_duration: 素材总时长
Returns:
镜头段列表 [(start, end), ...]
"""
segments: list[tuple[float, float]] = []
for i, ts in enumerate(scene_changes):
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
# 只保留有效长度的镜头段(至少 0.5 秒)
if end - ts >= 0.5:
segments.append((ts, end))
return segments
def _pick_start_in_scene_segment(
seg_start: float,
seg_end: float,
clip_duration: float,
) -> float | None:
"""在镜头段内随机选取一个起始时间点.
确保 start + clip_duration <= seg_end。
若镜头段长度不足以容纳片段,返回 None。
"""
available = seg_end - seg_start - clip_duration
if available < 0:
return None
max_start = seg_start + available
return random.uniform(seg_start, max_start)
def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str,
asset_ids: list[str],
) -> None:
"""后台任务:MediaKit 智能选并更新片段的起始时间.
"""后台任务:使SceneChange 智能选并更新片段的起始时间.
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
降级策略:
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
@@ -886,12 +942,6 @@ def _update_mediakit_recommendations_async( # pragma: no cover
asset_repo = SQLAlchemyAssetRepository(db)
plan_svc = EditPlanService(db)
# 调用 MediaKit 获取推荐时间
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
if not recommendations:
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
return
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
batch_size = 500
all_clips = []
@@ -914,15 +964,16 @@ 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 预分组片段时间段(消除 O(N^2) 嵌套循环
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段
clips_by_asset: dict[str, list] = defaultdict(list)
for clip in clips:
aid = getattr(clip, "asset_id", "") or ""
if aid and clip.start_time is not None:
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
if aid:
clips_by_asset[aid].append(clip)
for aid in clips_by_asset:
clips_by_asset[aid].sort(key=lambda c: c.order)
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
@@ -931,16 +982,22 @@ def _update_mediakit_recommendations_async( # pragma: no cover
updated_segments: dict[str, list[tuple[float, float]]] = {}
updated_count = 0
# 遍历片段,按 asset_id 匹配推荐时间
for clip in clips:
asset_id = getattr(clip, "asset_id", "") or ""
if not asset_id or asset_id not in recommendations:
# 尝试获取存储服务(用于生成视频 URL)
try:
storage = get_storage_service()
except Exception:
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
return
# 获取 MediaKit 客户端
client = get_mediakit_client()
# 对每个素材,检测场景切换点并分配镜头段
for asset_id in unique_asset_ids:
asset_clips = clips_by_asset.get(asset_id, [])
if not asset_clips:
continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id)
if not asset:
continue
@@ -948,89 +1005,147 @@ def _update_mediakit_recommendations_async( # pragma: no cover
if asset_total <= 0:
continue
# 推荐时间 + 片段时长不能超过素材总时长
if recommended_start + clip_duration > asset_total:
logger.info(
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
asset_id,
recommended_start,
clip_duration,
asset_total,
)
continue
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
other_segments: list[tuple[float, float]] = [
(cs, ce)
for cid, cs, ce in clips_by_asset.get(asset_id, [])
if cid != clip.id and cid not in updated_clip_ids
]
other_segments.extend(updated_segments.get(asset_id, []))
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
# 本 plan 片段创建时已写入历史记录
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
def _norm(segs):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
logger.info(
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
continue
# 逐个更新并捕获异常(单点失败不影响其他片段)
try:
old_start = clip.start_time
old_end = old_start + clip_duration
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
# 写新区间,最后统一 commit;任一步失败整体 rollback
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
plan_svc.update_clip(clip.id, start_time=recommended_start)
# 获取素材视频 URL
video_url: str | None = None
storage_key = getattr(asset, "storage_key", None) or ""
mime = getattr(asset, "mime_type", "") or ""
if storage_key and mime.startswith("video/"):
try:
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
video_url = storage.get_download_url(storage_key)
except Exception as e:
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
# 构建该素材的占用区间列表(排除已更新片段)
def _get_other_segments(asset_id_inner, clip_id_inner):
segs: list[tuple[float, float]] = []
for c in clips_by_asset.get(asset_id_inner, []):
cid = c.id
if cid != clip_id_inner and cid not in updated_clip_ids:
segs.append((c.start_time, c.start_time + c.duration))
segs.extend(updated_segments.get(asset_id_inner, []))
# 并入历史已用区间
def _norm(segs_in):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
# 优先使用 SceneChange 策略
scene_segments: list[tuple[float, float]] = []
if client.is_available and video_url:
scene_changes = client.detect_scene_changes(video_url)
if scene_changes is not None:
scene_segments = _build_scene_segments(scene_changes, asset_total)
logger.info(
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
asset_id,
len(scene_segments),
)
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
# 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,
)
continue
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
# 为每个片段分配不同的镜头段
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
for clip in asset_clips:
clip_duration = clip.duration
recommended_start: float | None = None
# 从镜头段池中依次尝试,选一个不冲突的
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool):
candidate_start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
if candidate_start is None:
continue # 镜头段太短,跳过
# 检查越界
if candidate_start + clip_duration > asset_total:
continue
# 检查与已用区间冲突
other_segs = _get_other_segments(asset_id, clip.id)
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
continue
recommended_start = candidate_start
# 消费该镜头段(从池中移除,下一个片段用不同镜头段)
scene_segments_pool.pop(seg_idx)
break
if recommended_start is None:
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底
used_segs_for_calc: dict[str, list[tuple[float, float]]] = {
asset_id: _get_other_segments(asset_id, clip.id)
}
fallback_start = _calc_random_start_time(
asset_id,
clip_duration,
{asset_id: asset_total},
used_segs_for_calc,
)
if fallback_start is None:
continue # 完全无法分配,保持原起点
recommended_start = fallback_start
# 更新片段起始时间
try:
old_start = clip.start_time
old_end = old_start + clip_duration
plan_svc.update_clip(clip.id, start_time=recommended_start)
try:
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
)
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
updated_segments.setdefault(asset_id, []).append(
(recommended_start, recommended_start + clip_duration)
)
logger.info(
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
@@ -13,6 +13,7 @@
from __future__ import annotations
import logging
import random
from typing import Any, List
from sqlalchemy.orm import Session
@@ -32,7 +33,7 @@ from packages.domain.plan_generator_utils import (
generate_default_clips,
map_clip_types_for_mode,
)
from packages.domain.smart_match import score_asset
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.domain.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__)
@@ -235,9 +236,12 @@ class PlanGeneratorService:
)
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
"""按 smart_match 综合评分降序排列素材 ID。
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)
评分高的素材(质量好、时长合适、新鲜、使用次数少)排在前面
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
"""
scored: list[tuple[str, float]] = []
for asset_id in asset_ids:
@@ -247,8 +251,11 @@ class PlanGeneratorService:
scored.append((asset_id, score))
else:
scored.append((asset_id, 0.0))
# 评分降序排列
scored.sort(key=lambda x: x[1], reverse=True)
# 评分 + 随机噪声后按降序排列
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]:
+2 -2
View File
@@ -1733,8 +1733,8 @@
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
@@ -221,7 +221,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
product.duplicateRate > 0 ? ` ${dupClass}` : ""
}`}
>
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
{product.duplicateRate != null ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
+7
View File
@@ -14,6 +14,13 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
# 素材选取排序时注入的随机噪声上限(分)。
# score_asset 综合得分范围为 0-100,噪声 0~20 意味着:
# - 素材间得分差距 > 20 分时,排名不受影响(质量差异显著的素材保持稳定优先级)
# - 得分接近(差距 <= 20 分)的素材排名会随机浮动,使每次生成选出的素材组合不同,
# 从素材组合层面降低成片重复率;排名靠后的低分素材也有机会入选。
SCORE_RANDOM_NOISE_MAX = 20.0
@dataclass
class SmartMatchResult:
+64
View File
@@ -119,6 +119,70 @@ class MediaKitClient:
return None
def detect_scene_changes(
self,
video_url: str,
max_frames: int = 20,
poll_interval: float = 2.0,
max_poll_attempts: int = 30,
) -> Optional[List[float]]:
"""检测视频场景切换点,返回时间戳列表.
降级策略:
1. 先尝试 SceneChange 策略
2. SceneChange 失败(OOM等)→ 退回 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,
+3 -1
View File
@@ -24,8 +24,10 @@ celery==5.4.0
# 对象存储
oss2==2.18.4
# HTTP 客户端
# HTTP 客户端pin 间接依赖防止版本漂移)
httpx==0.27.2
httpcore==1.0.7
h2==4.1.0
# Prometheus monitoring
prometheus-client==0.21.1
+1
View File
@@ -15,5 +15,6 @@ pytest-xdist==3.6.1
diff-cover==8.0.3
# 资产质量评分依赖(与 requirements-worker.txt 保持一致)
numpy==1.26.4
scipy==1.13.1
Pillow==10.4.0
+33 -4
View File
@@ -38,6 +38,28 @@ def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
return [(i, dur_min, dur_max) for i in range(count)]
def _patch_zero_noise():
"""消除 clips.py 排序随机噪声,用于确定性断言(如均衡分配)。
排序噪声(random.uniform(0, SCORE_RANDOM_NOISE_MAX))返回 0
其他 uniform 调用(片段时长随机)委托给独立 Random 实例,行为不变。
"""
import random as _stdlib_random
from app.api.routes.templates_editor import clips as clips_module
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
_fallback = _stdlib_random.Random()
def _fake_uniform(a, b):
if b == SCORE_RANDOM_NOISE_MAX:
return 0.0
return _fallback.uniform(a, b)
return patch.object(clips_module.random, "uniform", _fake_uniform)
def _patch_segments(segments=None):
return patch(
"app.api.routes.templates_editor.clips._get_template_segments",
@@ -130,7 +152,8 @@ class TestEditorClipsBySegments:
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
with _patch_segments(DEFAULT_SEGMENTS):
# 均衡分配由 use_count 贪心保证,消除排序噪声后确定性断言
with _patch_zero_noise(), _patch_segments(DEFAULT_SEGMENTS):
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
@@ -418,8 +441,13 @@ class TestEditorClipsDurationAndStartTime:
assert mock_calc.call_count == 2
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert clips_data[0]["start_time"] == 12.5
assert clips_data[1]["start_time"] == 18.0
# clips_data 按 order 排序,但分配顺序因 shuffle 而随机,
# 因此只验证两个 start_time 值都存在
start_times = {c["start_time"] for c in clips_data}
assert start_times == {12.5, 18.0}
# 验证 order 仍然有序
orders = [c["order"] for c in clips_data]
assert orders == sorted(orders)
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_asset_durations_deduped(self, mock_storage):
@@ -790,7 +818,8 @@ class TestClipsFromAssetsInvalidIds:
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"]) # type: ignore[list-item]
with _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
# 消除排序噪声,确定性断言两条合法素材各被使用
with _patch_zero_noise(), _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
+39 -12
View File
@@ -306,6 +306,27 @@ class TestGetTemplateSegments:
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
def _patch_zero_noise():
"""消除 clips.py 排序随机噪声(SCORE_RANDOM_NOISE_MAX 噪声返回 0)。
用于均衡分配等确定性断言;其他 uniform 调用(片段时长随机)行为不变。
"""
import random as _stdlib_random
from app.api.routes.templates_editor import clips as clips_module
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
_fallback = _stdlib_random.Random()
def _fake_uniform(a, b):
if b == SCORE_RANDOM_NOISE_MAX:
return 0.0
return _fallback.uniform(a, b)
return patch.object(clips_module.random, "uniform", _fake_uniform)
def _make_auth_user():
auth = MagicMock()
auth.user.id = "user-001"
@@ -446,8 +467,12 @@ class TestFromAssetsByTemplateSegments:
)
clips_data = _get_clips_data(mock_plan_svc)
assert 3.0 <= clips_data[0]["duration"] <= 5.0
assert 4.0 <= clips_data[1]["duration"] <= 8.0
# PR #1614 转场补偿:2 个片段时每 clip 时长 +(2-1)*0.5/2=0.25s
# (xfade 重叠在渲染时扣除,故 clip 时长 = segment 随机时长 + 补偿),
# 断言上界需计入补偿与一位小数舍入余量
comp = (2 - 1) * 0.5 / 2
assert 3.0 + comp - 0.1 <= clips_data[0]["duration"] <= 5.0 + comp + 0.1
assert 4.0 + comp - 0.1 <= clips_data[1]["duration"] <= 8.0 + comp + 0.1
def test_assets_balanced_assignment(self):
"""素材按使用次数贪心分配(使用少的优先),保证均衡使用。"""
@@ -466,16 +491,18 @@ class TestFromAssetsByTemplateSegments:
mock_asset_repo.get.side_effect = get_asset
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
# 消除排序噪声,确定性断言贪心均衡分配
with _patch_zero_noise():
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data(mock_plan_svc)
asset_ids = [c["asset_id"] for c in clips_data]
+257
View File
@@ -0,0 +1,257 @@
"""Tests for scene-change smart frame selection + random shuffle of segment processing."""
from __future__ import annotations
import random
from unittest.mock import MagicMock, patch
import pytest
from apps.api.app.api.routes.templates_editor.clips import (
_build_scene_segments,
_pick_start_in_scene_segment,
)
from packages.shared.mediakit_client import MediaKitClient
# ── Part 1: Random shuffle tests ──────────────────────────────────────────
class TestRandomShuffle:
"""验证 segments 处理顺序随机打乱逻辑."""
def test_same_segments_produce_different_asset_orders(self):
"""同一批 segments 多次处理,asset 分配顺序有变化.
模拟打乱后的处理顺序,验证多次运行中 asset_id 分配顺序
存在差异(概率性验证,运行 50 次应该至少出现 2 种排列)。
"""
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
asset_ids = ["A", "B", "C", "D"]
observed_orders: list[tuple] = set()
for _ in range(50):
shuffled_indices = list(range(len(segments)))
random.shuffle(shuffled_indices)
order_tuple = tuple(shuffled_indices)
observed_orders.add(order_tuple)
# 50 次打乱,4! = 24 种排列,应出现多种不同排列
assert len(observed_orders) > 1, "打乱应该产生多种不同顺序"
def test_clips_data_order_always_sorted(self):
"""clips_data 按 order 排序后始终有序.
模拟打乱处理后 clips_data 按 order 排序,验证最终 order 为 [0,1,2,3]。
"""
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
for _ in range(20):
shuffled_indices = list(range(len(segments)))
random.shuffle(shuffled_indices)
# 模拟构建 clips_data(用 _seg_order 作为 order
clips_data = []
for idx in shuffled_indices:
seg_order, _, _ = segments[idx]
clips_data.append({"order": seg_order, "asset_id": f"asset_{idx}"})
# 按 order 排序
clips_data.sort(key=lambda c: c["order"])
# 验证 order 始终有序
orders = [c["order"] for c in clips_data]
assert orders == [0, 1, 2, 3], f"排序后 order 应为 [0,1,2,3],实际为 {orders}"
# ── Part 2: detect_scene_changes tests ────────────────────────────────────
class TestDetectSceneChanges:
"""验证 MediaKitClient.detect_scene_changes 方法."""
def _make_client(self) -> MediaKitClient:
"""创建一个可用的 MediaKitClientmock 配置)."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "test-key"
mock_settings.return_value.mediakit_base_url = "http://test"
mock_settings.return_value.mediakit_timeout = 30
client = MediaKitClient()
return client
def test_scene_change_success(self):
"""SceneChange 策略成功返回时间戳列表."""
client = self._make_client()
mock_frames = [
{"image_url": "url1", "timestamp": 0.0},
{"image_url": "url2", "timestamp": 3.2},
{"image_url": "url3", "timestamp": 7.8},
{"image_url": "url4", "timestamp": 12.5},
]
with patch.object(client, "extract_frames", return_value=mock_frames):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is not None
assert result[0] == 0.0 # 始终以 0.0 开头
assert 3.2 in result
assert 7.8 in result
assert 12.5 in result
assert result == sorted(result) # 应已排序
def test_scene_change_fallback_to_time_interval(self):
"""SceneChange 失败降级到 TimeInterval 策略."""
client = self._make_client()
# 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果
fallback_frames = [
{"image_url": "url1", "timestamp": 0.0},
{"image_url": "url2", "timestamp": 5.0},
{"image_url": "url3", "timestamp": 10.0},
]
call_count = 0
def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# 第一次 SceneChange 失败
return None
else:
# 第二次 TimeInterval 成功
assert kwargs.get("strategy") == "TimeInterval"
return fallback_frames
with patch.object(client, "extract_frames", side_effect=side_effect):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is not None
assert result[0] == 0.0
assert 5.0 in result
assert 10.0 in result
def test_mediakit_not_available_returns_none(self):
"""MediaKit 不可用时返回 None."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "" # 未配置
mock_settings.return_value.mediakit_base_url = "http://test"
mock_settings.return_value.mediakit_timeout = 30
client = MediaKitClient()
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is None
def test_both_strategies_fail_returns_none(self):
"""SceneChange 和 TimeInterval 都失败时返回 None."""
client = self._make_client()
with patch.object(client, "extract_frames", return_value=None):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is None
def test_prepends_zero_if_not_present(self):
"""若帧列表中不包含 0.0,自动在开头添加."""
client = self._make_client()
# 帧列表中没有 timestamp=0.0
mock_frames = [
{"image_url": "url1", "timestamp": 2.0},
{"image_url": "url2", "timestamp": 5.5},
]
with patch.object(client, "extract_frames", return_value=mock_frames):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is not None
assert result[0] == 0.0
assert 2.0 in result
assert 5.5 in result
# ── Part 2.2: Scene segment building and assignment ───────────────────────
class TestSceneSegments:
"""验证镜头段构建和分配逻辑."""
def test_build_scene_segments(self):
"""从场景切换点正确构建镜头段."""
scene_changes = [0.0, 3.2, 7.8, 12.5]
asset_duration = 15.0
segments = _build_scene_segments(scene_changes, asset_duration)
assert len(segments) == 4
assert segments[0] == (0.0, 3.2)
assert segments[1] == (3.2, 7.8)
assert segments[2] == (7.8, 12.5)
assert segments[3] == (12.5, 15.0)
def test_build_scene_segments_filters_short(self):
"""过滤掉过短的镜头段(< 0.5秒)."""
scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0]
asset_duration = 12.0
segments = _build_scene_segments(scene_changes, asset_duration)
# (0.0, 0.1) 长度 0.1 < 0.5 → 过滤
# (0.1, 5.0) → 保留
# (5.0, 5.3) 长度 0.3 < 0.5 → 过滤
# (5.3, 10.0) → 保留
# (10.0, 12.0) → 保留
assert len(segments) == 3
assert segments[0] == (0.1, 5.0)
assert segments[1] == (5.3, 10.0)
assert segments[2] == (10.0, 12.0)
def test_pick_start_in_segment(self):
"""在镜头段内随机选取起始时间."""
seg_start = 3.0
seg_end = 8.0
clip_duration = 2.0
starts = set()
for _ in range(100):
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
assert start is not None
assert seg_start <= start <= seg_end - clip_duration
starts.add(round(start, 2))
# 应该有多个不同的起始时间
assert len(starts) > 1
def test_pick_start_segment_too_short(self):
"""镜头段太短无法容纳片段时返回 None."""
result = _pick_start_in_scene_segment(0.0, 1.0, 2.0)
assert result is None
def test_different_clips_from_different_scenes(self):
"""不同片段应来自不同的镜头段(模拟分配逻辑)."""
scene_changes = [0.0, 5.0, 10.0, 15.0]
asset_duration = 18.0
clip_duration = 3.0
segments = _build_scene_segments(scene_changes, asset_duration)
assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18)
# 模拟 3 个片段从不同镜头段取点
scene_pool = list(segments)
assigned_starts = []
for _ in range(3):
if not scene_pool:
break
seg_start, seg_end = scene_pool.pop(0)
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
assert start is not None
assigned_starts.append(start)
# 3 个片段分别从 3 个不同镜头段中选取
assert len(assigned_starts) == 3
# 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12]
assert 0.0 <= assigned_starts[0] <= 2.0
assert 5.0 <= assigned_starts[1] <= 7.0
assert 10.0 <= assigned_starts[2] <= 12.0
+154 -3
View File
@@ -23,7 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from packages.domain.smart_match import score_asset, smart_select_assets
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset, smart_select_assets
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
@@ -158,6 +158,38 @@ def _make_auth_user():
return auth
def _make_zero_noise_patcher(module):
"""构造 patch(module.random.uniform):噪声调用(上界=SCORE_RANDOM_NOISE_MAX)返回 0。
其他 uniform 调用(如片段时长随机)委托给一个独立的 Random 实例,
避免递归回已 patch 的全局函数。
"""
import random as _stdlib_random
_fallback = _stdlib_random.Random()
def _fake_uniform(a, b):
if b == SCORE_RANDOM_NOISE_MAX:
return 0.0
return _fallback.uniform(a, b)
return patch.object(module.random, "uniform", _fake_uniform)
def _patch_zero_noise_clips():
"""消除 clips.py 排序噪声,其他 uniform 调用不受影响。"""
from app.api.routes.templates_editor import clips as clips_module
return _make_zero_noise_patcher(clips_module)
def _patch_zero_noise_plan_service():
"""消除 plan_generator_service.py 排序噪声,其他 uniform 调用不受影响。"""
from app.services import plan_generator_service as svc_module
return _make_zero_noise_patcher(svc_module)
class TestFromAssetsSmartMatchIntegration:
"""验证 clips.py 中 sorted_candidates 使用 smart_match 评分。"""
@@ -180,6 +212,7 @@ class TestFromAssetsSmartMatchIntegration:
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
with (
_patch_zero_noise_clips(),
patch(
"app.api.routes.templates_editor.clips._get_template_segments",
return_value=segments,
@@ -219,6 +252,63 @@ class TestFromAssetsSmartMatchIntegration:
first_clip_asset == "a_fresh"
), f"第一个片段应分配给 smart_match 分更高的 a_fresh,实际是 {first_clip_asset}"
def test_score_noise_causes_varied_selection(self):
"""得分接近(差距 < SCORE_RANDOM_NOISE_MAX)的素材,多次生成的素材组合应有变化。
两条同等质量/时长/新鲜度的素材(use_count 相同),smart_match 得分一致,
噪声让两者的相对排名随机浮动,多次调用首个片段的素材分布应两者都出现。
"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
def _get_asset(aid):
return _make_mock_asset_for_clips(aid, 30.0, 0)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
first_assets: set[str] = set()
for _ in range(30):
mock_plan_svc = MagicMock()
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
with (
patch(
"app.api.routes.templates_editor.clips._get_template_segments",
return_value=segments,
),
patch(
"app.api.routes.templates_editor.clips.get_used_segments",
return_value={},
),
patch(
"app.api.routes.templates_editor.clips.record_used_segments",
return_value=None,
),
):
body = ClipsFromAssetsRequest(
asset_ids=["a_x", "a_y"],
required_clips_count=2,
)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id=f"plan-noise-{len(first_assets)}-{_}",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = mock_plan_svc.replace_all_clips_transactional.call_args.args[1]
first_assets.add(clips_data[0]["asset_id"])
assert first_assets == {
"a_x",
"a_y",
}, f"噪声应使两条等分素材的排名浮动,30 次调用首个片段应覆盖两者,实际 {first_assets}"
# ── 一键生成路径集成测试 ─────────────────────────────────────────────────────
@@ -247,7 +337,8 @@ class TestPlanGeneratorSmartMatchIntegration:
db = MagicMock()
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
with _patch_zero_noise_plan_service():
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
# low_use (0次) 应排第一,high_use (10次) 应排最后
assert sorted_ids[0] == "low_use"
@@ -284,7 +375,10 @@ class TestPlanGeneratorSmartMatchIntegration:
EditPlanClip(id="c2", plan_id="p1", clip_type="main", duration=5.0, order=1),
]
with patch("app.services.plan_generator_service.distribute_assets") as mock_dist:
with (
_patch_zero_noise_plan_service(),
patch("app.services.plan_generator_service.distribute_assets") as mock_dist,
):
svc._distribute_assets(
clips,
["old_asset", "new_asset"],
@@ -322,3 +416,60 @@ class TestPlanGeneratorSmartMatchIntegration:
)
# random_selection=True 时不应调用 asset_repo.get(不执行排序)
mock_asset_repo.get.assert_not_called()
class TestPlanGeneratorScoreNoise:
"""验证一键生成路径的评分排序注入了随机噪声。"""
def test_equal_scores_produce_varied_order(self):
"""两条 smart_match 得分相同的素材,多次排序的首位应覆盖两者。"""
from app.services.plan_generator_service import PlanGeneratorService
def _get_asset(aid):
asset = MagicMock()
asset.id = aid
asset.duration = 15.0
asset.quality_score = None
asset.created_at = None
asset.metadata = {"generation_use_count": 0}
return asset
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
first_ids: set[str] = set()
for _ in range(30):
order = svc._sort_assets_by_smart_score(["equal_a", "equal_b"])
first_ids.add(order[0])
assert first_ids == {
"equal_a",
"equal_b",
}, f"噪声应使等分素材排名浮动,30 次排序首位应覆盖两者,实际 {first_ids}"
def test_large_score_gap_not_flipped(self):
"""得分差距远大于噪声上限时,低分素材不会因噪声超过高分素材。
quality 100 vs 0 → quality 维度差距 40 分 > 噪声上限 20,
其余维度完全一致,50 次排序高质量素材必须始终排第一。
"""
from app.services.plan_generator_service import PlanGeneratorService
def _get_asset(aid):
quality = {"top": 100.0, "bad": 0.0}[aid]
asset = MagicMock()
asset.id = aid
asset.duration = 15.0
asset.quality_score = quality
asset.created_at = None
asset.metadata = {"generation_use_count": 0}
return asset
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
for _ in range(50):
order = svc._sort_assets_by_smart_score(["top", "bad"])
assert order[0] == "top", f"质量差距 40 分 > 噪声上限,top 应始终排第一,实际 {order}"