Compare commits

..

27 Commits

Author SHA1 Message Date
saas-backend-agent f6c18b3aa9 chore: schema 快照仅增量 is_default 列+部分唯一索引(修正缩进格式)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m6s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 3m29s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m10s
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 22s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 199h47m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 199h47m49s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 199h47m49s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 199h53m53s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 199h54m0s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 199h47m50s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 199h54m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 199h54m6s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 199h53m54s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 199h54m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 199h53m55s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 200h22m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 200h28m35s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 200h28m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 200h28m42s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 200h28m48s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 200h28m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 200h28m47s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 200h28m43s
2026-09-08 02:10:10 +08:00
saas-backend-agent 6b1966ce90 feat: #1775 默认项目/默认素材库幂等化(DB 唯一约束兜底)
问题:小程序端 getOrCreateDefaultProject 在重试/并发/重复调用下,
仅靠应用层"先查后插"不保证幂等,会给同一用户重复创建默认项目。
素材库虽有唯一约束 uq_asset_libraries_project_kind(迁移058),
但 ensure-default 接口未捕获冲突,并发下直接 500。

改动:
1. projects 表新增 is_default 列 + 部分唯一索引
   uq_projects_owner_default(owner_user_id WHERE is_default=true),
   保证每用户至多一个默认项目(迁移069,存量"默认项目"最早一条回填)
2. Project 实体/Model/Repository 增加 is_default 字段
3. 新增 get_or_create_default_project / find_default_by_owner:
   INSERT 冲突时捕获 IntegrityError 回滚重查,返回已有记录不报500
4. 素材库仓储新增 get_or_create_default_library(同上幂等语义)
5. 新增 POST /projects/ensure-default 组合接口:
   一次请求幂等返回默认项目 + 三类默认素材库ID
6. ensure-default 素材库接口改用幂等仓储方法(修复并发500)
7. in-memory 仓储同步幂等方法

测试:15 个新测试(并发10次只建1个、跨用户独立、IntegrityError恢复、
字段语义),99 个相关旧测试全过。
2026-09-08 02:09:29 +08:00
xiaoxia 691c811cd4 chore: 删除无引用的旧7步流程遗留组件 GenerationStatus (#1782)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 20s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Security (push) Successful in 5m57s
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
CI/CD Pipeline / Unit Tests (push) Failing after 8m20s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 206h28m53s
CI/CD Pipeline / Deploy Production (push) Failing after 206h28m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 206h28m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 206h28m57s
CI/CD Pipeline / CI Gate (push) Failing after 206h28m57s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 206h33m32s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 206h36m23s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 206h36m42s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 206h36m23s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 206h36m24s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 206h36m47s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 206h36m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 206h36m54s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 206h36m54s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 206h37m0s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 206h37m1s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 206h37m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 206h37m1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 206h37m13s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 206h37m2s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 206h37m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 206h37m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 206h37m14s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 206h37m15s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 206h37m16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 206h37m15s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 206h37m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 206h37m17s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 206h37m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 206h37m21s
CI/CD Pipeline / PR Build API Image (push) Failing after 206h37m21s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 206h37m22s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 207h3m39s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 207h11m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 207h11m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h11m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 207h11m57s
CI/CD Pipeline / PR Build Web Image (push) Failing after 207h12m3s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:46:49 +08:00
xiaoxia 2114b7e7ae fix: Step5 播放器上移+去完成提示;Step6 封面页去视频全宽居中 (#1781)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 14s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 14s
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m0s
CI/CD Pipeline / Validate - Security (push) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Unit Tests (push) Failing after 8m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 10m56s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 12m44s
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 206h39m21s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 206h39m24s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 206h39m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 206h39m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 206h43m6s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 206h39m25s
CI/CD Pipeline / CI Gate (push) Failing after 206h43m19s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 206h39m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 206h39m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 206h43m19s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 206h39m26s
CI/CD Pipeline / Build Production API Image (push) Failing after 206h43m19s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 206h39m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 206h39m26s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 206h49m12s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 206h49m12s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 206h50m11s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 206h50m48s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 206h50m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 206h51m45s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 206h51m45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 206h51m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 206h51m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 206h51m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 206h51m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 206h51m59s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 206h51m50s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 206h51m52s
CI/CD Pipeline / PR Build API Image (push) Failing after 206h52m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 206h52m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 207h14m6s
CI/CD Pipeline / Build Production Web Image (push) Failing after 207h18m1s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h23m54s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 207h25m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h26m26s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:31:06 +08:00
xiaoxia 7766ba1479 fix(#1774): 模板不存在明确返回404并收敛双表异常降级逻辑 (#1779)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
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
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 206h53m51s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 206h53m52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 206h53m55s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 206h53m55s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 206h53m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 206h53m55s
CI/CD Pipeline / Frontend Lint (push) Failing after 206h53m56s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 206h54m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 206h54m2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 207h28m33s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 207h28m36s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 207h28m36s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h28m37s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:30:08 +08:00
xiaoxia ef686dde8f fix(#1769): 模板列表过滤无片段配置的无效模板,避免前端选中后400 (#1772)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 47s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m24s
CI/CD Pipeline / Validate - Security (push) Successful in 7m3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m39s
CI/CD Pipeline / Unit Tests (push) Successful in 9m55s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 207h3m47s
CI/CD Pipeline / Deploy Production (push) Failing after 207h3m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 207h3m50s
CI/CD Pipeline / Build Production Web Image (push) Failing after 207h3m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 207h3m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 207h9m51s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 207h12m1s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h12m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 207h12m4s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 207h12m22s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 207h12m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 207h12m29s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 207h12m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 207h13m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 207h13m21s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 207h13m26s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 207h13m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h13m38s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 207h13m39s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 207h13m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 207h13m45s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 207h13m48s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 207h13m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 207h13m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h13m49s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 207h13m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 207h13m53s
CI/CD Pipeline / CI Gate (push) Failing after 207h38m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 207h47m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 207h47m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 207h48m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 207h48m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 207h48m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 207h48m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 207h48m30s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:10:13 +08:00
xiaoxia 01026156ae fix(#1777): Step2素材库只显示视频库 + 失效模板运行时自动回退 (#1780)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m13s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 207h14m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 207h14m41s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 207h14m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 207h14m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 207h14m51s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 207h14m54s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 207h15m1s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 207h15m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 207h15m6s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 207h15m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 207h15m7s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 207h15m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 207h16m30s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 207h16m33s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 207h16m28s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 207h16m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 207h16m29s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 207h16m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h16m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 207h16m51s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 207h16m52s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 207h16m54s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 207h49m24s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h49m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 207h49m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 207h51m10s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 207h51m18s
CI/CD Pipeline / PR Build Web Image (push) Failing after 207h51m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:07:18 +08:00
xiaoxia 42c0885813 Merge pull request 'fix: #1765 generate_pixel_perturbation 支持 int seed 可复现' (#1778) from fix/pixel-perturbation-seed into develop
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 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Integration Tests (push) Successful in 1m38s
CI/CD Pipeline / Validate - Style (push) Successful in 2m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m6s
CI/CD Pipeline / Validate - Security (push) Successful in 16m13s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 207h42m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 207h42m56s
CI/CD Pipeline / Deploy Production (push) Failing after 207h42m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 207h42m56s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 207h58m42s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 207h58m42s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 207h56m11s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 207h56m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 207h56m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 208h16m30s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 208h16m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h19m37s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h19m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 208h19m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 208h19m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 208h20m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 208h20m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 208h20m48s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 208h22m3s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 208h22m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 208h22m25s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 208h22m25s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 208h22m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 208h22m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 208h22m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 208h22m27s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 208h22m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 207h59m10s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 208h22m33s
CI/CD Pipeline / PR Build API Image (push) Failing after 207h59m11s
CI/CD Pipeline / Frontend Lint (push) Failing after 207h59m11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 208h17m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 208h33m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 208h33m54s
CI/CD Pipeline / CI Gate (push) Failing after 208h17m37s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 208h33m23s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 208h54m23s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 208h56m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 208h57m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 208h57m6s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 208h57m8s
2026-09-07 23:01:36 +08:00
saas-backend-agent cd6d8615e6 test: #1765 补充 int seed 分支测试,diff coverage 达标
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m16s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 208h16m18s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 208h16m18s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 208h16m24s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 208h16m25s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h36m16s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h36m16s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 208h36m23s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 208h36m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 208h36m35s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 208h36m35s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 208h36m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 208h36m41s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 208h36m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 208h36m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 208h36m43s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 208h36m50s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 208h51m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 209h11m9s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 209h11m20s
新增 5 个测试覆盖 int seed 入参分支(reproducible/与 Random 对象
等价/None 默认),满足 diff coverage ≥60% 要求。
2026-09-07 22:47:22 +08:00
saas-backend-agent fcc7863b31 fix: #1765 generate_pixel_perturbation 支持 int seed 参数
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 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h47m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h47m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 208h48m3s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 208h48m7s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 208h48m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 208h48m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 208h48m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 208h48m9s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 208h48m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 208h48m10s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 208h48m17s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 209h22m37s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 209h22m48s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 209h22m48s
与 get_rhythm_template(seed) 接口保持一致:传入 int 时自动构造
random.Random(seed),保证基于 variantSeed 可复现;Random 对象和
None 调用方式不变。
2026-09-07 22:35:49 +08:00
xiaoxia 6af2f3c08d Merge pull request 'fix: 修复 compute_duplicate_rate 方法丢失导致 27 个单测失败' (#1773) from fix/dedup-method-restore into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
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 (push) Successful in 23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m47s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m11s
CI/CD Pipeline / Validate - Security (push) Successful in 22m1s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 208h38m51s
CI/CD Pipeline / CI Gate (push) Failing after 208h38m55s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 208h38m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 208h38m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 208h56m56s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 208h57m59s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 208h58m0s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 208h58m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 208h58m13s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 208h58m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 208h58m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 208h58m7s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 209h0m24s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 209h0m24s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 209h0m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 209h0m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 209h0m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 209h0m54s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 209h0m52s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 209h0m54s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 209h0m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 209h0m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 209h0m57s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 209h0m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 209h0m59s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 209h0m56s
CI/CD Pipeline / PR Build API Image (push) Failing after 209h1m0s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 209h1m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 209h1m2s
CI/CD Pipeline / Deploy Production (push) Failing after 209h13m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 209h13m35s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 209h32m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 209h33m9s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 209h35m4s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 209h35m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 209h35m33s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 209h35m36s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 209h35m39s
2026-09-07 22:23:11 +08:00
CI Bot 410c390cf0 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m30s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 19m0s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m28s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 209h1m13s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 209h1m16s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 209h1m16s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 209h1m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 209h19m59s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 209h20m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 209h20m7s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 209h20m12s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 209h20m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 209h20m16s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 209h20m16s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 209h20m19s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 209h20m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 209h20m19s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 209h20m22s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 209h35m53s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 209h54m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 209h54m51s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 209h54m54s
2026-09-07 14:03:49 +00:00
saas-backend-agent 9f85d40855 fix: 修复 compute_duplicate_rate 方法被错误嵌套到模块函数内部
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 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 209h22m35s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 209h22m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 209h22m42s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 209h22m49s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 209h22m49s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 209h22m47s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 209h22m48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 209h22m48s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 209h22m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 209h22m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 209h22m52s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 209h22m53s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 209h57m16s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 209h57m29s
PR #1762 查重增强时,compute_duplicate_rate 方法被意外放到
compute_structure_similarity 模块函数体内(return 之后的死代码),
导致 VideoDeduplicator 类丢失该方法,27 个旧单测 AttributeError。

将方法移回 VideoDeduplicator 类,缩进不变。
验证:test_duplicate_rate/test_duplicate_rate_scope/
test_dedup_1702/test_bad_fingerprint_filter/test_dedup_enhanced
共 78 个测试全部通过。
2026-09-07 22:01:13 +08:00
xiaoxia 034eaac695 Merge pull request 'feat: 像素级扰动滤镜降低平台查重风险 (Issue #1765)' (#1771) from feat/pixel-perturbation-1765 into develop
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 10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (push) Failing after 8m12s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m22s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Security (push) Successful in 22m17s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 210h5m40s
CI/CD Pipeline / CI Gate (push) Failing after 210h5m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 210h5m42s
CI/CD Pipeline / Build Production API Image (push) Failing after 210h5m42s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 210h12m34s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 210h12m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 210h12m38s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 210h12m35s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 210h12m40s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 210h12m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 210h12m39s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 210h12m40s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 210h12m41s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 210h12m40s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 210h12m41s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 210h12m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 210h12m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 210h12m41s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 210h12m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 210h24m24s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 210h12m41s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 210h26m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 210h26m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 210h27m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 210h27m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 210h27m56s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 210h28m4s
CI/CD Pipeline / Deploy Production (push) Failing after 210h40m19s
CI/CD Pipeline / Build Production Web Image (push) Failing after 210h40m21s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 210h47m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 210h47m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 210h47m19s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 210h47m20s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 210h47m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 211h0m41s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 210h47m20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 210h47m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 211h2m35s
2026-09-07 20:56:11 +08:00
saas-backend-agent 27dccf6591 fix: #1769 智能剪辑 Step2 素材加载 400 错误
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Validate - Style (push) Successful in 2m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m51s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Unit Tests (push) Failing after 8m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m1s
CI/CD Pipeline / Validate - Security (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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 210h41m31s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 210h41m32s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 210h41m37s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 210h41m43s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 210h41m51s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 210h41m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 210h41m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 210h41m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 210h42m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 210h42m5s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 210h42m10s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 210h42m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 210h43m25s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 210h43m25s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 210h42m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 210h43m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 210h43m26s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 210h43m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 210h43m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 210h43m27s
CI/CD Pipeline / Frontend Lint (push) Failing after 210h45m3s
CI/CD Pipeline / PR Build Web Image (push) Failing after 210h45m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 210h45m36s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 210h45m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 210h45m38s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 211h16m11s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 211h16m48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 211h16m55s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 211h18m4s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 211h18m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h18m6s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 211h20m15s
- 根因:模板 f7578c059c2e4dfca2236eb86cb6808a 是无效测试模板
  - is_active=false(已失效)
  - template_clip_configs=0(无片段配置)
  - template_segments=0(无旧版片段配置)
- 修复:_get_template_segments 添加详细错误日志,便于排查
- 前端应过滤 is_active=false 的模板
- 用户应选择 clip_config_count > 0 的有效模板
2026-09-07 20:38:35 +08:00
CI Bot ac7ab679b7 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m57s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m27s
AI Code Review / AI Code Review (pull_request) Successful in 6m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m25s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 19m24s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m33s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 210h35m22s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 210h35m26s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 210h35m26s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 210h35m26s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 210h52m4s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 210h52m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 210h52m58s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 210h54m28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 210h54m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 210h54m30s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 210h54m57s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 210h54m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 210h54m57s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 210h54m44s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 210h54m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 211h10m1s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 211h26m44s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h29m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 211h29m37s
2026-09-07 12:28:56 +00:00
saas-backend-agent bb98620a8a feat: 像素级扰动滤镜降低平台查重风险 (Issue #1765)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 210h55m5s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 210h55m9s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 210h55m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 210h55m36s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 210h55m43s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 210h56m22s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 210h59m0s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h29m47s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h30m15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 211h31m2s
- generate_pixel_perturbation():随机选 2-3 种滤镜组合
  - noise: 轻微噪声 (0.01~0.02)
  - unsharp: 锐化/柔化 (-0.5~+0.5)
  - curves: 对比度微调 (0.95~1.05)
  - color_balance: RGB 通道偏移
- _apply_pixel_perturbation():渲染时追加到 FFmpeg filter chain
- edit_plan_service:批量变体生成时为每个变体生成不同像素扰动
- 参数幅度确保肉眼不可见(SSIM > 0.95),帧级差异 > 3%
- 11 个新测试覆盖滤镜组合/参数范围/验收
2026-09-07 20:24:13 +08:00
xiaoxia 2a0b75c007 Merge pull request 'feat: 节奏模板让批量变体片段时长分布不同 (Issue #1764)' (#1770) from feat/rhythm-templates-1764 into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 6s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Check push changed paths (push) Successful in 35s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / Integration Tests (push) Successful in 1m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Style (push) Successful in 2m41s
CI/CD Pipeline / Build Staging API Image (push) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Failing after 11m13s
AI Code Review / AI Code Review (pull_request) Failing after 10m34s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 12m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m3s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m25s
CI/CD Pipeline / Validate - Security (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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 210h49m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 210h55m9s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 210h55m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 210h55m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 210h55m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 210h55m27s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 210h55m30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 210h55m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 210h58m29s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 210h59m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h0m7s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 210h59m8s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 211h0m8s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 210h59m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 211h1m29s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h2m11s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 211h2m5s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 211h2m20s
CI/CD Pipeline / PR Build Web Image (push) Failing after 211h2m6s
CI/CD Pipeline / PR Build API Image (push) Failing after 211h2m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 211h34m46s
2026-09-07 20:21:38 +08:00
CI Bot 0d9d4e584f style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 12m53s
Preview Cleanup / Cleanup Preview Environment (pull_request) Failing after 13m1s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m23s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 210h41m47s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 210h41m49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 210h42m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 210h42m2s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 210h42m4s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 211h2m58s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 211h3m9s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h3m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 211h2m58s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 211h3m12s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 211h2m58s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 211h3m13s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h3m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 211h3m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 211h3m13s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h3m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 211h37m44s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 211h37m51s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 211h37m59s
2026-09-07 12:20:53 +00:00
saas-backend-agent eb089fa26d feat: 节奏模板让批量变体片段时长分布不同 (Issue #1764)
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 / PR Build API Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 211h5m53s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 211h5m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 211h6m2s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h6m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 211h6m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 211h6m4s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 211h6m4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h6m6s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 211h6m6s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h6m9s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 211h40m35s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 211h40m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 211h40m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 211h40m46s
- 新增 RHYTHM_TEMPLATES 节奏模板池(6 种权重序列)
- get_rhythm_template(seed):根据变体 seed 随机选模板
- adapt_template_length():将模板适配到实际片段数
- plan_clip_durations() 支持 rhythm_template 参数按权重分配时长
- 保底约束:每段 >= 2s,总时长 = 配音时长
- edit_plan_service: ensure_batch_variant_plans 为每个变体生成不同节奏模板
- 17 个新测试覆盖模板池/适配/分配/验收
2026-09-07 20:17:52 +08:00
saas-backend-agent 1ec274b9f5 ci: trigger push pipeline for dedup enhanced
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 55s
CI/CD Pipeline / Validate - Style (push) Successful in 2m29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Unit Tests (push) Failing after 7m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 6m11s
CI/CD Pipeline / Validate - Security (push) Successful in 16m42s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 211h15m1s
CI/CD Pipeline / CI Gate (push) Failing after 211h15m3s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 211h15m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 211h15m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 211h15m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 211h27m48s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 211h29m4s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 211h28m58s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 211h29m4s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 211h29m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 211h29m4s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 211h29m5s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 211h29m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 211h29m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 211h29m8s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 211h29m6s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 211h29m8s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h29m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 211h31m37s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 211h29m11s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 211h31m37s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 211h31m39s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 211h31m37s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 211h31m40s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h31m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 211h31m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 211h31m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 211h31m50s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 211h31m49s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h31m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 211h31m56s
CI/CD Pipeline / Deploy Production (push) Failing after 211h49m42s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 212h3m43s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 212h3m46s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 212h6m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 212h6m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 212h6m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 212h6m28s
2026-09-07 19:51:52 +08:00
xiaoxia 62820391c3 fix: Step5 单视频全宽布局+视频播放器居中 (#1761)
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) Blocked by required conditions
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Check push changed paths (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Retag skipped Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Retag skipped Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
2026-09-07 19:51:42 +08:00
xiaoxia 322b0a082c Merge pull request 'feat(dedup): 增加文案+结构维度查重 (P2-后端3)' (#1762) from feat/dedup-enhanced-text-structure into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
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
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 58s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Failing after 211h33m11s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h33m52s
CI/CD Pipeline / PR Build Web Image (push) Failing after 211h33m12s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 211h34m8s
CI/CD Pipeline / PR Build API Image (push) Failing after 211h33m12s
CI/CD Pipeline / Frontend Lint (push) Failing after 211h33m15s
2026-09-07 19:50:00 +08:00
CI Bot 07aa03da24 style: auto-format with black + isort + prettier [skip ci-format-check]
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 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m12s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m3s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m23s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 16m23s
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 211h18m29s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 211h18m32s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 211h18m32s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 211h18m32s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 211h34m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 211h34m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 211h34m28s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h34m30s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 211h34m30s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 211h34m47s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 211h34m52s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 211h34m57s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h34m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h34m57s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 211h53m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 212h9m5s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 212h9m9s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 212h9m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 212h9m36s
2026-09-07 11:49:16 +00:00
saas-backend-agent add93bb9de feat(dedup): 增加文案+结构维度查重 (Issue #P2-后端3)
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 / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m16s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 211h36m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 211h36m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 211h36m42s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h36m47s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 211h36m47s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 211h37m12s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 211h37m13s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 211h37m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 211h37m19s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h37m21s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 212h11m17s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 212h11m26s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 212h11m45s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 212h11m58s
- 新增 compute_text_similarity: 字符级 Jaccard 相似度比对配音文本
- 新增 compute_structure_similarity: 片段序列相似度(数量+类型+时长分布)
- 多维度融合公式: duplicate_rate = visual*0.5 + text*0.25 + structure*0.25
- 文案/结构数据缺失时降级为纯视觉维度
- 21 个新测试覆盖文本+结构+权重常量
2026-09-07 19:46:43 +08:00
xiaoxia 6fe3e03d6a Merge pull request 'feat: 封面智能选帧 — 帧质量评分(清晰度/亮度/色彩)' (#1759) from feat/cover-frame-scoring into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m51s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m43s
CI/CD Pipeline / Validate - Style (push) Successful in 2m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Unit Tests (push) Successful in 8m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m0s
CI/CD Pipeline / Validate - Security (push) Successful in 10m32s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 211h45m14s
CI/CD Pipeline / CI Gate (push) Failing after 211h45m18s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 211h45m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 211h51m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 211h45m18s
CI/CD Pipeline / Build Production API Image (push) Failing after 211h45m18s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 211h52m42s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 211h54m6s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 211h52m42s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 211h54m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 211h52m42s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 211h54m15s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 211h55m31s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 211h54m22s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 211h55m31s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 211h55m33s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 211h55m33s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 211h55m41s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 211h55m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 211h55m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 211h55m42s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 211h55m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 211h55m44s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 211h55m45s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 211h55m46s
CI/CD Pipeline / PR Build API Image (push) Failing after 211h55m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 211h55m51s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 211h55m53s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 211h55m54s
CI/CD Pipeline / Deploy Production (push) Failing after 212h19m53s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 212h28m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 212h29m1s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 212h30m12s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 212h30m13s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 212h30m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 212h30m21s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 212h30m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 212h30m27s
2026-09-07 19:28:22 +08:00
xiaoxia d73c0a77d8 fix: Step5 BatchGenerationGrid 单卡居中 + maxWidth 240px (#1760)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Style (push) Successful in 2m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Validate - Security (push) Successful in 5m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m38s
CI/CD Pipeline / Unit Tests (push) Successful in 8m28s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / PR Build API Image (pull_request) Successful in 25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 58s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m11s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 212h8m11s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 212h8m17s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 212h8m17s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 212h15m36s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 212h15m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 212h15m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 212h15m46s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 212h15m52s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 212h15m58s
CI/CD Pipeline / Canary Release to Production (push) Failing after 212h24m8s
CI/CD Pipeline / CI Gate (push) Failing after 212h24m12s
CI/CD Pipeline / Build Production Web Image (push) Failing after 212h24m12s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 212h24m12s
CI/CD Pipeline / Build Production API Image (push) Failing after 212h24m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 212h29m27s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 212h31m10s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 212h31m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 212h31m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 212h15m55s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 212h15m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 212h15m57s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 212h15m55s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 212h15m57s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 212h15m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 212h32m38s
CI/CD Pipeline / PR Build Web Image (push) Failing after 212h32m43s
CI/CD Pipeline / PR Build API Image (push) Failing after 212h32m43s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 212h32m46s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 212h42m50s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 212h42m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 212h50m15s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 212h50m25s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 212h50m31s
CI/CD Pipeline / Deploy Production (push) Failing after 212h58m47s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 212h50m25s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 212h50m35s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 212h50m31s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 213h7m22s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 18:51:29 +08:00
56 changed files with 2789 additions and 648 deletions
@@ -0,0 +1,72 @@
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
Revision ID: 069_project_is_default
Revises: 068_user_profile_completed
Create Date: 2026-09-08
背景:
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
改动:
1. projects 表新增 is_default 布尔列(默认 false)
2. 部分唯一索引 uq_projects_owner_default:(owner_user_id) WHERE is_default = true
—— 保证每个用户至多一个默认项目
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
(只标记不删除;存量重复项目的清理另行确认后单独执行)
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
"""
import sqlalchemy as sa
from alembic import op
revision = "069_project_is_default"
down_revision = "068_user_profile_completed"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. 新增 is_default 列
op.add_column(
"projects",
sa.Column(
"is_default",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
op.execute("""
UPDATE projects p
SET is_default = true
WHERE p.id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY owner_user_id
ORDER BY created_at ASC, id ASC
) AS rn
FROM projects
WHERE name = '默认项目'
) t
WHERE t.rn = 1
)
""")
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
op.execute("""
CREATE UNIQUE INDEX uq_projects_owner_default
ON projects (owner_user_id)
WHERE is_default = true
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
op.drop_column("projects", "is_default")
+5 -24
View File
@@ -20,7 +20,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain import AssetLibraryKind
from ._helpers import check_project_access
@@ -120,30 +120,11 @@ def ensure_default_library(
kind = AssetLibraryKind(request.kind)
# 查找该项目下同 kind 的素材库,返回第一个
existing = asset_library_repository.find_by_project(request.project_id)
for lib in existing:
if lib.kind == kind:
return _to_asset_library_response(lib)
# 不存在 → 自动创建
import uuid
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind,
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
library = AssetLibrary(
id=str(uuid.uuid4()),
project_id=request.project_id,
name=default_name,
kind=kind,
asset_count=0,
total_size=0,
created_at=now,
updated_at=now,
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
return _to_asset_library_response(library)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
+41 -1
View File
@@ -1,13 +1,14 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_project_repository
from app.dependencies import get_asset_library_repository, get_project_repository
from app.schemas.project import (
CreateProjectRequest,
ListProjectsResponse,
ProjectResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from packages.application import (
CreateProjectCommand,
@@ -16,10 +17,20 @@ from packages.application import (
GetProjectUseCase,
ListProjectsUseCase,
)
from packages.domain import AssetLibraryKind
router = APIRouter()
class DefaultContextResponse(BaseModel):
"""幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。"""
project_id: str
image_library_id: str
video_library_id: str
voice_library_id: str
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
@@ -72,6 +83,35 @@ def create_project(
return _to_project_response(project)
@router.post("/ensure-default", response_model=DefaultContextResponse)
def ensure_default_project_and_libraries(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> DefaultContextResponse:
"""幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。
- 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default)
- 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind)
- 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500
- 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条
"""
user_id = authenticated_user.user.id
project = project_repository.get_or_create_default_project(user_id)
libraries = {}
for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE):
library = asset_library_repository.get_or_create_default_library(project.id, kind)
libraries[kind] = library.id
return DefaultContextResponse(
project_id=project.id,
image_library_id=libraries[AssetLibraryKind.IMAGE],
video_library_id=libraries[AssetLibraryKind.VIDEO],
voice_library_id=libraries[AssetLibraryKind.VOICE],
)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_project(
project_id: str,
+5
View File
@@ -106,6 +106,10 @@ def list_templates(
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
valid_only: bool = Query(
False,
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
@@ -116,6 +120,7 @@ def list_templates(
tag=tag,
keyword=keyword,
mode=mode,
valid_only=valid_only,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
@@ -399,68 +393,42 @@ def _safe_segment_duration(value, default: float) -> float:
def _get_template_segments(
template_id: str,
user_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id)/
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于归属校验)
tpl_svc: 模板编辑器服务
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
[(segment_order, duration_min, duration_max), ...] 按 order 排序;
模板存在但未配置片段时返回空列表。
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
return []
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
def _recommended_time_conflicts(
@@ -663,13 +631,21 @@ def create_clips_from_assets_editor(
7. 素材时长为 0 或缺失时报 400,不创建无效片段
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
try:
segments = _get_template_segments(template_id, user_id, tpl_svc)
except TemplateNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在或无权访问",
) from exc
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="模板未配置片段",
)
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
@@ -41,29 +41,33 @@ def get_draft_plan_id(
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
兼容策略:优先从新模板系统(edit_templates 表)查找,
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
模板读取遵循单一数据源、显式判定(不使用异常降级):
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
模板不存在、已删除或不归属于当前用户时,一律返回 404。
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get_active(template_id, user_id)
is_global_template = tpl_svc.get_template(template_id) is not None
if old_template is None and not is_global_template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 1. 草稿已存在 → 直接返回
draft = tpl_svc.get_template_draft(template_id)
if draft is not None:
return draft.id
# 2. 新系统有模板 → 用新服务创建草稿
if tpl_svc.get_template(template_id) is not None:
# 2. 全局模板(新系统)→ 用新服务创建草稿
if is_global_template:
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
return draft.id
# 3. 回退到旧模板系统(templates 表)
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get(template_id, user_id=user_id)
if old_template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 4. 基于旧模板创建草稿计划
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
@@ -150,7 +150,7 @@ def rollback_template(
try:
tpl = tpl_svc.rollback_to_version(template_id, request.version)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
clip_configs = tpl_svc.list_clip_configs(template_id)
return EditorRollbackResponse(
+36 -2
View File
@@ -784,11 +784,17 @@ class EditPlanService:
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
# #1764:从 plan config 读取节奏模板
rhythm_template = None
if plan and hasattr(plan, "config") and plan.config:
rhythm_template = plan.config.get("rhythm_template")
target = plan_clip_durations(
len(clips),
voice,
transition_effects=[c.transition_effect for c in clips],
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
rhythm_template=rhythm_template,
)
if not target:
return None
@@ -907,6 +913,25 @@ class EditPlanService:
)
plan_ids.append(variant.id)
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
if voice_durations and len(voice_durations) > 0:
# 从源 plan 获取片段数
source_plan = self.get_plan(source_plan_id)
if source_plan and hasattr(source_plan, "clips"):
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
rhythm_templates_for_variants = []
if clip_count > 0:
for idx in range(len(plan_ids)):
# 每个变体用不同的 seed 选择节奏模板
variant_seed = rng.randint(0, 999999)
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(template)
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
from packages.domain.variant_plan_selector import generate_visual_perturbation
@@ -916,8 +941,17 @@ class EditPlanService:
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
if idx == 0:
perturbation["hflip"] = False
self.update_plan_config(pid, {"visual_perturbation": perturbation})
logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation)
config_update = {"visual_perturbation": perturbation}
# #1764:写入节奏模板
if idx < len(rhythm_templates_for_variants):
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
# #1765:写入像素级扰动滤镜
from packages.domain.variant_plan_selector import generate_pixel_perturbation
pixel_pert = generate_pixel_perturbation(rng)
config_update["pixel_perturbation"] = pixel_pert
self.update_plan_config(pid, config_update)
logger.info("变体 %d 视觉扰动+像素扰动: plan=%s vis=%s pix=%s", idx, pid, perturbation, pixel_pert)
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
+65 -1
View File
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
logger = logging.getLogger(__name__)
class TemplateNotFoundError(Exception):
"""模板不存在、已删除或当前用户无权访问.
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
"""
def __init__(self, template_id: str) -> None:
self.template_id = template_id
super().__init__(f"模板不存在: {template_id}")
class EditTemplateService:
"""模板管理服务
@@ -217,7 +228,14 @@ class EditTemplateService:
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""列出模板的片段配置"""
"""列出模板的片段配置
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
"""
# 确保模板存在
self.get_template_or_raise(template_id)
return self._clip_config_repo.list_by_template(
@@ -227,6 +245,52 @@ class EditTemplateService:
limit=limit,
)
def list_clip_configs_for_editor(
self,
template_id: str,
user_id: str,
*,
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""编辑器读取模板片段配置的单一数据源入口.
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
模板主表按双表现状显式判定,不使用 try/except 控制流:
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于旧表模板归属校验)
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 1) 用户自建模板(旧表 templates,归属 user_id)
if self._clip_config_repo.template_owned_by(template_id, user_id):
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
if self._template_repo.get(template_id) is not None:
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 3) 两表都没有:不存在 / 已删除 / 无权限
raise TemplateNotFoundError(template_id)
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
"""获取片段配置详情"""
return self._clip_config_repo.get(config_id)
+16 -4
View File
@@ -5,10 +5,22 @@ import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetLibraryItem } from "./types"
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries")
return response.data.items || []
/**
* 获取当前用户的素材库
*
* @param kind 可选,按素材库类型过滤(video/voice/image)。
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
* 混进来(#1777:视频选择器只展示视频库)。
*/
export const getAssetLibraries = async (
kind?: AssetLibraryItem["kind"],
): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
params: kind ? { kind } : undefined,
})
const items = response.data.items || []
return kind ? items.filter((lib) => lib.kind === kind) : items
}
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
+13
View File
@@ -55,6 +55,19 @@ apiClient.interceptors.response.use(
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
/**
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
*/
_silentErrorToast?: boolean
}
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
if (originalRequest?._silentErrorToast) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true
return Promise.reject(error)
}
// 401 → 尝试刷新 Token
@@ -12,17 +12,25 @@ import type {
ListCategoriesResponse,
} from "./types"
/** 获取模板列表 */
/** 获取模板列表
*
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
* 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772);
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
*/
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
validOnly?: boolean
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
...(params?.validOnly ? { valid_only: true } : {}),
},
})
let list = response.data.items
+7 -2
View File
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{ timeout: 60000, signal: opts?.signal },
{
timeout: 60000,
signal: opts?.signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
return response.data
}
@@ -43,11 +43,17 @@ export async function updateEditPlanClips(
templateId: string,
clips: EditPlanClipInput[],
signal?: AbortSignal,
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
silentErrorToast?: boolean,
): Promise<{ count: number }> {
const response = await apiClient.put(
`/templates/${templateId}/editor/clips`,
{ clips },
{ signal },
{
signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
return response.data
}
+58 -53
View File
@@ -45,6 +45,7 @@ const GeneratePage: React.FC = () => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -416,13 +417,11 @@ const GeneratePage: React.FC = () => {
/* ── 最终成片(单视频右侧播放) ── */
const finalVideo = generatedVideos[0]
/* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
const layoutClassName = useMemo(() => {
if (currentStep < 4) return "xx-generate-layout full-width"
if (currentStep === 4) return "xx-generate-layout step4-layout"
// 步骤5/6:批量网格需要整行宽度;单视频保持 表单+右侧成片 两栏
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
}, [currentStep, isBatch])
return "xx-generate-layout full-width"
}, [currentStep])
/* ================================================================
渲染
@@ -522,6 +521,7 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
onTemplateInvalid={handleInvalidTemplate}
generating={generating}
generated={generated}
generateError={generateError}
@@ -543,6 +543,59 @@ const GeneratePage: React.FC = () => {
selectedVariantIds={selectedVariantIds}
/>
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && (
<div
style={{
display: "flex",
justifyContent: "center",
marginTop: 16,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
maxWidth: 320,
width: "100%",
}}
>
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
⬇️ 下载
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
</div>
)}
<GenerateStepActions
currentStep={currentStep}
onPrev={goPrev}
@@ -554,54 +607,6 @@ const GeneratePage: React.FC = () => {
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
</div>
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
{currentStep >= 5 && !isBatch && generated && finalVideo && (
<div className="xx-generate-right-col">
<div
className="xx-inline-video-player"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
}}
>
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
⬇️ 下载
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
</div>
)}
</div>
{/* 数量选择弹窗 */}
@@ -34,12 +34,26 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
</span>
</div>
<div className="xx-batch-gen-grid">
<div
className="xx-batch-gen-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 180px))",
justifyContent: "center",
justifyItems: "center",
gap: 14,
width: "100%",
}}
>
{sorted.map((task) => {
const title = titles[task.variantIndex] || `视频 ${task.variantIndex + 1}`
const video = (task.videos?.[0] || null) as GeneratedVideo | null
return (
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div
key={task.taskId}
className={`xx-batch-gen-card status-${task.status}`}
style={{ maxWidth: 240 }}
>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" ? (
@@ -50,6 +50,8 @@ export interface GenerateStepContentProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
/* 生成 */
generating: boolean
generated: boolean
@@ -108,6 +110,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVoice,
onSelectedVoiceChange,
onServerClipsChange,
onTemplateInvalid,
generating,
generated,
generateError,
@@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate}
templateSegments={templateSegments}
onServerClipsChange={onServerClipsChange}
onTemplateInvalid={onTemplateInvalid}
/>
)
case 3:
@@ -189,7 +193,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
case 5:
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方) */
if (previewCount > 1) {
return (
<BatchGenerationGrid
@@ -199,10 +203,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
}
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
if (generated && !generating && !generateError) return null
return (
<div className="xx-form-section">
<h3>🎬 确认生成</h3>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
@@ -234,14 +238,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
</button>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
<div className="xx-gen-success-sub">右侧可预览成片,点击「下一步」选择封面</div>
</div>
</div>
)}
</div>
)
case 6:
@@ -23,6 +23,8 @@ interface Step2MaterialSelectProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label>选择视频库</label>
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
{m.libraries.length === 0 && !m.materialsLoading ? (
<div className="xx-empty-state">
<p>暂无视频素材库</p>
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
请先在「素材库」中创建视频素材库并上传视频
</p>
</div>
) : (
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
)}
</div>
{m.materialMode === "manual" && (
@@ -1,117 +0,0 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
return (
<div style={{ marginTop: 16 }}>
{!generating && !generated && !generateError && (
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">🎬</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
<div className="xx-gen-progress-sub">
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
</div>
</div>
</div>
</div>
)}
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">视频生成完成!</div>
<div className="xx-gen-success-sub">
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onScrollToPreview}
>
查看结果
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title">生成失败</div>
<div className="xx-gen-error-msg">
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄 重试
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
知道了
</button>
</div>
</div>
)}
</div>
)
}
export default GenerationStatus
+7 -29
View File
@@ -117,10 +117,6 @@
grid-template-columns: 1fr;
}
.xx-generate-layout.full-width .xx-generate-right-col {
display: none;
}
/* ============================================================
左侧表单区 generate-form
============================================================ */
@@ -2323,28 +2319,6 @@
生成结果(右侧)
================================================================ */
.xx-generate-right-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ── 内联视频播放器(右侧) ── */
.xx-inline-video-player {
width: 100%;
max-width: 320px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.xx-inline-video-player video {
background: #000;
}
.xx-preview-header {
display: flex;
align-items: center;
@@ -2710,11 +2684,12 @@
/* ── 封面设置区域改造样式 ── */
/* 封面操作按钮区 */
/* 封面操作按钮区(单视频全宽页居中) */
.xx-cover-actions {
display: flex;
gap: 12px;
margin-bottom: 12px;
justify-content: center;
}
/* 已选模板文字 */
@@ -3202,10 +3177,11 @@
margin: 0;
}
/* ── 批量封面网格 ── */
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
.xx-cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
justify-content: center;
gap: 16px;
}
@@ -3403,6 +3379,7 @@
第5步确认生成:批量渲染进度网格(Issue #1677)
============================================================ */
.xx-batch-gen-grid {
justify-items: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
@@ -3481,6 +3458,7 @@
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
@media (max-width: 960px) {
.xx-batch-gen-grid {
justify-items: center;
grid-template-columns: minmax(0, 320px);
}
}
@@ -8,11 +8,22 @@ import type { AssetItem } from "@/api/assets"
* 管理素材库列表、当前选中库、素材列表加载
*/
export function useMaterialLibrary() {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
/* ── 素材库数据 API ──
* Step2 是视频选片,只拉取 kind=video 的素材库(#1777):
* 后端按 kind 查询参数过滤,前端 getAssetLibraries("video") 再兜底过滤一次,
* 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。
* queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。
*/
const { data: allLibraries = [] } = useQuery({
queryKey: ["asset-libraries", "video"],
queryFn: () => getAssetLibraries("video"),
staleTime: 60_000,
})
// 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤)
const libraries = useMemo(
() => allLibraries.filter((lib) => lib.kind === "video"),
[allLibraries],
)
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
@@ -40,6 +40,8 @@ export interface GenerateFormState {
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */
handleInvalidTemplate: () => boolean
/* 素材 */
selectedMaterials: string[]
@@ -131,7 +133,8 @@ export const useGenerateFormState = (): GenerateFormState => {
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } =
useTemplateSelection()
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
@@ -228,6 +231,7 @@ export const useGenerateFormState = (): GenerateFormState => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -0,0 +1,81 @@
/**
* 失效模板判定与自动回退工具(#1777)
*
* 背景:用户进入生成页后,之前选中的模板可能已被删除、或从未配置片段。
* 调用片段相关接口(PUT/POST /templates/{id}/editor/clips[...]/from-assets)时:
* - 模板不存在 → 后端返回 404(并行工单 #1774 把「模板不存在」统一为该状态码)
* - 模板无片段配置 → 当前部分场景返回 400(detail 含「片段配置」),
* 参数校验类错误返回 422
* 这三类响应都说明「当前选中的模板不可用于生成」,应清除失效选择并自动切换到
* 第一个有效模板,同时提示用户,而不是让页面卡死、无任何反馈。
*/
import type { EditingTemplate } from "@/api/editing-planner"
/** 失效模板相关的 HTTP 状态码 */
const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422])
/**
* 从任意抛出值(axios 错误)提取 HTTP 状态码。
* 非 axios 错误 / 无响应时返回 null。
*/
export function getHttpStatus(err: unknown): number | null {
if (!err || typeof err !== "object") return null
const status = (err as { response?: { status?: number }; status?: number })?.response?.status
return typeof status === "number" ? status : null
}
/** 安全提取后端错误文本(detail/message/msg,422 数组也兜底拼一下) */
function extractErrorText(err: unknown): string {
if (!err || typeof err !== "object") return ""
const data = (err as { response?: { data?: unknown } })?.response?.data
if (!data) return ""
try {
const text = JSON.stringify(data)
return typeof text === "string" ? text : ""
} catch {
return ""
}
}
/**
* 判断一次 clips/from-assets 请求失败是否因为「模板失效」。
*
* 严格判定,避免把无关的 400/422(例如素材参数问题)误判为模板失效:
* - 404:模板/编辑计划不存在,一定是模板失效
* - 400:仅当后端文本明确提到「片段配置」(无片段配置无法创建片段)才判定
* - 422:参数校验类,from-assets 场景下命中「片段/segments」相关字段才判定
*/
export function isInvalidTemplateError(err: unknown): boolean {
const status = getHttpStatus(err)
if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false
if (status === 404) return true
const text = extractErrorText(err)
if (status === 400) {
// 后端当前返回:「模板没有片段配置,无法创建片段」
return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text)
}
// 422:FastAPI 校验错误,命中模板片段相关字段
return /segment|clip|片段|模板/i.test(text)
}
/**
* 判断模板是否可用于生成(有效模板)。
*
* 有效 = 处于激活态(is_active !== false,字段缺失视为 true 兼容旧后端)
* 且至少配置了一个片段。
* 与后端 valid_only 过滤口径保持一致(#1769/#1772),这里是前端双保险。
*/
export function isValidTemplate(template: EditingTemplate | null | undefined): boolean {
if (!template) return false
if (template.is_active === false) return false
return (template.segments?.length ?? 0) > 0
}
/** 从模板列表中取出第一个有效模板,没有则返回 null */
export function findFirstValidTemplate(
templates: EditingTemplate[] | null | undefined,
): EditingTemplate | null {
if (!Array.isArray(templates)) return null
return templates.find(isValidTemplate) ?? null
}
@@ -1,22 +1,87 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { useQuery } from "@tanstack/react-query"
import { message } from "antd"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { findFirstValidTemplate, isValidTemplate } from "./templateFallback"
/** 失效模板自动切换的提示文案 */
export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换"
export function useTemplateSelection() {
// selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL,
// 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4)
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
// valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。
// 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。
queryFn: () => getEditingTemplates({ validOnly: true }),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
// 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底,
// 保证下拉/自动选择只包含可用于生成的有效模板
const validTemplates = allTemplates.filter(isValidTemplate)
const userTemplates = validTemplates
return { selectedTemplate, setSelectedTemplate, userTemplates }
// 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值)
const templatesRef = useRef(validTemplates)
templatesRef.current = validTemplates
const selectedRef = useRef(selectedTemplate)
selectedRef.current = selectedTemplate
// 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast;
// 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示
const fallbackNotifiedRef = useRef<string>("")
/* 自动选择:模板加载完成且当前未选中时,自动选中第一个有效模板。
* 用户手动选择(setSelectedTemplate 被显式调用)后 selectedTemplate 非空,
* 本 effect 直接 return,绝不覆盖用户的手动选择(#1777 要求 4:手动优先)。 */
useEffect(() => {
if (selectedTemplate) return
const firstValid = validTemplates[0]
if (firstValid) {
setSelectedTemplate(firstValid.id)
}
}, [validTemplates, selectedTemplate])
/** 用户手动选择模板:优先级最高,重置失效提示标记 */
const handleSelectTemplate = useCallback((id: string) => {
fallbackNotifiedRef.current = ""
setSelectedTemplate(id)
}, [])
/**
* 运行时失效回退(#1777 要求 3):
* 创建片段接口返回 404(模板不存在)/ 400/422(模板无片段配置)时调用。
* - 清除失效选择,自动切换到第一个有效模板,并 toast 提示;
* - 没有有效模板时清空选择,Step1 展示明确的「暂无可用模板」空状态引导,
* 不让用户卡在失效模板上。
* 返回 true 表示已按「模板失效」处理(调用方可据此静默原始错误提示)。
*/
const handleInvalidTemplate = useCallback((): boolean => {
const current = selectedRef.current
// 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发)
if (current && fallbackNotifiedRef.current === current) return true
const fallback = findFirstValidTemplate(templatesRef.current)
fallbackNotifiedRef.current = current || "__empty__"
if (fallback) {
setSelectedTemplate(fallback.id)
message.warning(INVALID_TEMPLATE_FALLBACK_TOAST)
} else {
// 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建
setSelectedTemplate("")
message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段")
}
return true
}, [])
return {
selectedTemplate,
setSelectedTemplate: handleSelectTemplate,
userTemplates,
handleInvalidTemplate,
}
}
@@ -10,6 +10,7 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -24,6 +25,8 @@ interface UseStep2MaterialsProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
export function useStep2Materials({
@@ -36,6 +39,7 @@ export function useStep2Materials({
selectedTemplate,
templateSegments,
onServerClipsChange,
onTemplateInvalid,
}: UseStep2MaterialsProps) {
const {
libraries,
@@ -102,6 +106,8 @@ export function useStep2Materials({
selectedTemplateRef.current = selectedTemplate
const onServerClipsChangeRef = useRef(onServerClipsChange)
onServerClipsChangeRef.current = onServerClipsChange
const onTemplateInvalidRef = useRef(onTemplateInvalid)
onTemplateInvalidRef.current = onTemplateInvalid
useEffect(() => {
const tid = selectedTemplateRef.current
@@ -123,11 +129,12 @@ export function useStep2Materials({
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示)
await updateEditPlanClips(tid, [], controller.signal, true)
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
silentErrorToast: true,
})
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
const clipList = await getEditPlanClips(tid, { limit: 500 })
@@ -146,6 +153,14 @@ export function useStep2Materials({
message.error("智能选片失败,请重试")
return
}
// 模板失效(404 模板不存在 / 400/422 无片段配置):
// 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777)
if (isInvalidTemplateError(err)) {
console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err)
onServerClipsChangeRef.current?.([])
onTemplateInvalidRef.current?.()
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
}
}, 800)
@@ -41,7 +41,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
@@ -24,7 +24,7 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
// ── 获取 voice 类型素材库 ─────────────────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
staleTime: 60_000,
})
@@ -26,7 +26,7 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
@@ -0,0 +1,140 @@
/**
* 失效模板判定/回退纯函数单测(#1777)
*/
import { describe, it, expect } from "vitest"
import type { EditingTemplate } from "@/api/editing-planner"
import {
getHttpStatus,
isInvalidTemplateError,
isValidTemplate,
findFirstValidTemplate,
} from "@/pages/generate/hooks/useGenerateFormState/templateFallback"
function makeTemplate(partial: Partial<EditingTemplate> & { id: string }): EditingTemplate {
return {
name: partial.id,
mode: "pip",
category: "默认",
tags: [],
title_config: {
ai_auto_select: false,
content: "",
font_preset: "",
font_color: "",
font_size: 28,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "",
color: "",
size: 20,
animation: "",
},
bgm_config: { enabled: false, music_id: "" },
segments: [{ segment_order: 0, material_type: null }],
is_active: true,
created_at: "",
updated_at: "",
...partial,
} as EditingTemplate
}
function axiosError(status: number, data?: unknown) {
return { isAxiosError: true, response: { status, data } }
}
describe("getHttpStatus", () => {
it("提取 axios 错误的 HTTP 状态码", () => {
expect(getHttpStatus(axiosError(404))).toBe(404)
expect(getHttpStatus(axiosError(400))).toBe(400)
})
it("非 axios/无响应错误返回 null", () => {
expect(getHttpStatus(new Error("network"))).toBeNull()
expect(getHttpStatus(null)).toBeNull()
expect(getHttpStatus(undefined)).toBeNull()
expect(getHttpStatus({ isAxiosError: true })).toBeNull()
})
})
describe("isInvalidTemplateError", () => {
it("404 始终判定为模板失效(模板不存在)", () => {
expect(isInvalidTemplateError(axiosError(404))).toBe(true)
expect(isInvalidTemplateError(axiosError(404, { detail: "Not Found" }))).toBe(true)
})
it("400 且后端文案提到「片段配置」判定为模板无片段配置", () => {
expect(
isInvalidTemplateError(axiosError(400, { detail: "模板没有片段配置,无法创建片段" })),
).toBe(true)
})
it("400 但文案与片段配置无关 → 不误判", () => {
expect(isInvalidTemplateError(axiosError(400, { detail: "素材参数错误" }))).toBe(false)
})
it("422 命中片段/模板字段判定为失效", () => {
expect(
isInvalidTemplateError(
axiosError(422, { detail: [{ loc: ["body", "segments"], msg: "field required" }] }),
),
).toBe(true)
})
it("其他状态码(401/403/500/超时/网络)不判定为模板失效", () => {
expect(isInvalidTemplateError(axiosError(401))).toBe(false)
expect(isInvalidTemplateError(axiosError(403))).toBe(false)
expect(isInvalidTemplateError(axiosError(500))).toBe(false)
expect(isInvalidTemplateError({ code: "ECONNABORTED", message: "timeout of 60000ms" })).toBe(
false,
)
expect(isInvalidTemplateError(new Error("Network Error"))).toBe(false)
})
})
describe("isValidTemplate", () => {
it("有片段且未被标记 inactive → 有效", () => {
expect(isValidTemplate(makeTemplate({ id: "t1" }))).toBe(true)
})
it("segments 为空 → 无效(无片段配置)", () => {
expect(isValidTemplate(makeTemplate({ id: "t2", segments: [] }))).toBe(false)
})
it("is_active=false → 无效(已停用/删除)", () => {
expect(isValidTemplate(makeTemplate({ id: "t3", is_active: false }))).toBe(false)
})
it("is_active 字段缺失时视为有效(兼容旧后端)", () => {
const t = makeTemplate({ id: "t4" })
delete (t as Partial<EditingTemplate>).is_active
expect(isValidTemplate(t)).toBe(true)
})
it("null/undefined → 无效", () => {
expect(isValidTemplate(null)).toBe(false)
expect(isValidTemplate(undefined)).toBe(false)
})
})
describe("findFirstValidTemplate", () => {
it("跳过无效模板,返回第一个有效模板", () => {
const list = [
makeTemplate({ id: "empty", segments: [] }),
makeTemplate({ id: "inactive", is_active: false }),
makeTemplate({ id: "valid1" }),
makeTemplate({ id: "valid2" }),
]
expect(findFirstValidTemplate(list)?.id).toBe("valid1")
})
it("全部无效 → null(用于空状态引导)", () => {
expect(
findFirstValidTemplate([
makeTemplate({ id: "a", segments: [] }),
makeTemplate({ id: "b", is_active: false }),
]),
).toBeNull()
})
it("空数组/null → null", () => {
expect(findFirstValidTemplate([])).toBeNull()
expect(findFirstValidTemplate(null)).toBeNull()
expect(findFirstValidTemplate(undefined)).toBeNull()
})
})
@@ -0,0 +1,74 @@
/**
* useMaterialLibrary Hook 单测(#1777)
* - Step2 视频库选择器只拉取 kind=video 的素材库,配音库(voice)/图片库(image) 不混入
* - 自动选中第一个视频库
*/
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
import type { AssetItem, AssetLibraryItem } from "@/api/assets"
vi.mock("@/api/assets", () => ({
getAssetLibraries: vi.fn(),
getAssets: vi.fn(),
isAssetUsable: vi.fn(() => true),
}))
import { getAssetLibraries, getAssets } from "@/api/assets"
import { useMaterialLibrary } from "@/pages/generate/hooks/step2-materials/useMaterialLibrary"
const mockGetLibraries = vi.mocked(getAssetLibraries)
const mockGetAssets = vi.mocked(getAssets)
function lib(id: string, kind: AssetLibraryItem["kind"], name = id): AssetLibraryItem {
return { id, name, kind }
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return ({ children }: { children: ReactNode }) =>
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
}
beforeEach(() => {
vi.clearAllMocks()
mockGetAssets.mockResolvedValue({ items: [] as AssetItem[], total: 0 })
})
describe("useMaterialLibrary (#1777 kind=video 过滤)", () => {
it("按 kind=video 拉取素材库(后端参数过滤)", async () => {
mockGetLibraries.mockResolvedValueOnce([lib("v1", "video")])
renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalledTimes(1))
expect(mockGetLibraries).toHaveBeenCalledWith("video")
})
it("下拉库列表只包含视频库(自动选中第一个视频库)", async () => {
mockGetLibraries.mockResolvedValueOnce([
lib("voice-1", "voice"),
lib("img-1", "image"),
lib("video-1", "video"),
lib("video-2", "video"),
])
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.libraries).toHaveLength(2))
expect(result.current.libraries.map((l) => l.id)).toEqual(["video-1", "video-2"])
expect(result.current.libraries.every((l) => l.kind === "video")).toBe(true)
// 自动选中第一个视频库
expect(result.current.selectedLibraryId).toBe("video-1")
})
it("没有视频库时库列表为空且不自动选中(UI 展示空状态)", async () => {
mockGetLibraries.mockResolvedValueOnce([lib("voice-1", "voice"), lib("img-1", "image")])
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalled())
expect(result.current.libraries).toEqual([])
expect(result.current.selectedLibraryId).toBe("")
})
})
@@ -0,0 +1,174 @@
/**
* useTemplateSelection Hook 单测(#1777)
* - 自动选择跳过无片段/inactive 模板,只选第一个有效模板
* - 传 validOnly=true 给后端
* - 用户手动选择优先,自动逻辑不覆盖
* - handleInvalidTemplate:失效时自动切到第一个有效模板 + toast;无有效模板时清空
* - selectedTemplate 仅内存态,不写入 localStorage/sessionStorage
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
// antd message mock(拦截 toast)——vi.hoisted 保证 mock 工厂可引用
const { messageMock } = vi.hoisted(() => ({
messageMock: {
warning: vi.fn(),
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
loading: vi.fn(() => vi.fn()),
},
}))
vi.mock("antd", () => ({ message: messageMock }))
vi.mock("@/api/editing-planner", () => ({
getEditingTemplates: vi.fn(),
}))
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { useTemplateSelection } from "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
const mockGetTemplates = vi.mocked(getEditingTemplates)
function tpl(id: string, partial: Partial<EditingTemplate> = {}): EditingTemplate {
return {
id,
name: id,
mode: "pip",
category: "默认",
tags: [],
title_config: {
ai_auto_select: false,
content: "",
font_preset: "",
font_color: "",
font_size: 28,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "",
color: "",
size: 20,
animation: "",
},
bgm_config: { enabled: false, music_id: "" },
segments: [{ segment_order: 0, material_type: null }],
is_active: true,
created_at: "",
updated_at: "",
...partial,
} as EditingTemplate
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return ({ children }: { children: ReactNode }) =>
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe("useTemplateSelection (#1777)", () => {
it("请求模板时传 validOnly=true,并自动选中第一个有片段的有效模板", async () => {
mockGetTemplates.mockResolvedValueOnce([
tpl("empty", { segments: [] }),
tpl("inactive", { is_active: false }),
tpl("valid-a"),
tpl("valid-b"),
])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("valid-a"))
expect(mockGetTemplates).toHaveBeenCalledWith({ validOnly: true })
// 暴露给 UI 的 userTemplates 已过滤掉无效模板
expect(result.current.userTemplates.map((t) => t.id)).toEqual(["valid-a", "valid-b"])
})
it("列表全部无效时 selectedTemplate 为空(交空状态引导),不选中失效模板", async () => {
mockGetTemplates.mockResolvedValueOnce([
tpl("empty", { segments: [] }),
tpl("inactive", { is_active: false }),
])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetTemplates).toHaveBeenCalled())
// 给 effect 一个 tick
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
expect(result.current.userTemplates).toHaveLength(0)
})
it("用户手动选择优先:自动逻辑不会覆盖手动选择", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("a"), tpl("b")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("a"))
act(() => result.current.setSelectedTemplate("b"))
expect(result.current.selectedTemplate).toBe("b")
// 重新渲染 / refetch 后仍保持用户的手动选择
await waitFor(() => expect(result.current.selectedTemplate).toBe("b"))
})
it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] }), tpl("good")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
// 自动选中有效模板 good(bad 无片段不会被自动选中)
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
messageMock.warning.mockClear()
// 模拟运行时用户停留在一个已失效的模板 id(外部/草稿态),触发回退
act(() => result.current.setSelectedTemplate("stale-id"))
expect(result.current.selectedTemplate).toBe("stale-id")
act(() => {
const handled = result.current.handleInvalidTemplate()
expect(handled).toBe(true)
})
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
expect(messageMock.warning).toHaveBeenCalledWith("原模板已失效,已自动切换")
})
it("handleInvalidTemplate:无有效模板时清空选择并提示去创建", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] })])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.userTemplates).toHaveLength(0))
act(() => result.current.setSelectedTemplate("stale-id"))
act(() => {
result.current.handleInvalidTemplate()
})
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
expect(messageMock.warning).toHaveBeenCalledWith(expect.stringContaining("没有可用模板"))
})
it("失效模板 ID 不写入任何持久化存储", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("good")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
act(() => result.current.setSelectedTemplate("stale-invalid-id"))
act(() => result.current.handleInvalidTemplate())
const ls = JSON.stringify(localStorage)
const ss = JSON.stringify(sessionStorage)
expect(ls).not.toContain("stale-invalid-id")
expect(ss).not.toContain("stale-invalid-id")
// URL 也不含
expect(window.location.href).not.toContain("stale-invalid-id")
})
})
+181 -10
View File
@@ -7,6 +7,7 @@ import hashlib
import logging
import math
import os
import re
import statistics
import tempfile
from dataclasses import dataclass, field
@@ -56,8 +57,13 @@ MAX_GAP = 2 # 允许的最大间隙帧数
NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致)
# ── 融合判定常量 ────────────────────────────────────────────────
PHASH_WEIGHT = 0.7 # pHash 权重
HISTOGRAM_WEIGHT = 0.3 # 直方图权重
PHASH_WEIGHT = 0.7 # pHash 权重(视觉内部)
HISTOGRAM_WEIGHT = 0.3 # 直方图权重(视觉内部)
# ── 多维度查重融合权重(Issue #P2-后端3) ────────────────────────
VISUAL_WEIGHT = 0.5 # 视觉相似度权重(pHash+直方图)
TEXT_WEIGHT = 0.25 # 文案相似度权重(配音文本)
STRUCTURE_WEIGHT = 0.25 # 结构相似度权重(片段序列)
MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate)至少 70% 帧匹配
PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复
DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值
@@ -1057,12 +1063,11 @@ class VideoDeduplicator:
) -> dict:
"""计算当前视频与已有视频的查重率百分比。
新公式(双指标加权):
- frame_match_rate = 汉明距离 < PHASH_THRESHOLD 的帧数 / 总帧数
- temporal_coverage_rate = 连续匹配片段总时长 / 视频总时长
- duplicate_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100
visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(归一化到 0~1)
多维度融合公式(Issue #P2-后端3):
- visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(视觉维度)
- text_similarity = 文案 Jaccard 相似度(文案维度)
- structure_similarity = 片段序列相似度(结构维度)
- duplicate_rate = (visual*0.5 + text*0.25 + structure*0.25) * 100
对每个匹配视频都算,取最高 duplicate_rate。
@@ -1093,6 +1098,29 @@ class VideoDeduplicator:
match_count = 0
evaluated = 0
# Issue #P2-后端3: 加载当前视频的文案+结构数据
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, GeneratedVideoModel
current_video_obj = (
session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == current_video_id).first()
if current_video_id
else None
)
current_plan_id = getattr(current_video_obj, "edit_plan_id", "") or ""
current_clips_data = []
current_text_content = ""
if current_plan_id:
current_clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.plan_id == current_plan_id)
.order_by(EditPlanClipModel.order)
.all()
)
current_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in current_clips]
# 拼接所有片段的文本内容
current_text_content = " ".join(c.text_content for c in current_clips if c.text_content)
for existing in existing_videos:
if current_video_id and existing.id == current_video_id:
continue
@@ -1160,8 +1188,47 @@ class VideoDeduplicator:
# Issue #1702: 去掉 "frame_match_rate<0.3 整条跳过" 硬门槛——
# 局部片段复用帧比例天然低;coverage 为主指标,0 匹配自然得 0 分。
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage
dup_rate = (min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6) * 100
# 视觉维度:0.4 * frame_match_rate + 0.6 * temporal_coverage
visual_sim = min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6
# Issue #P2-后端3: 文案+结构维度
existing_plan_id = getattr(existing, "edit_plan_id", "") or ""
existing_clips_data = []
existing_text_content = ""
if existing_plan_id:
existing_clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.plan_id == existing_plan_id)
.order_by(EditPlanClipModel.order)
.all()
)
existing_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in existing_clips]
existing_text_content = " ".join(c.text_content for c in existing_clips if c.text_content)
# 计算文案相似度(有文案才算)
text_sim = (
compute_text_similarity(current_text_content, existing_text_content)
if (current_text_content and existing_text_content)
else 0.0
)
# 计算结构相似度(有片段才算)
structure_sim = (
compute_structure_similarity(current_clips_data, existing_clips_data)
if (current_clips_data and existing_clips_data)
else 0.0
)
# 多维度融合:visual*0.5 + text*0.25 + structure*0.25
# 如果文案/结构数据缺失,只用视觉维度(visual 权重提升到 1.0)
if current_text_content and existing_text_content and current_clips_data and existing_clips_data:
dup_rate = (
visual_sim * VISUAL_WEIGHT + text_sim * TEXT_WEIGHT + structure_sim * STRUCTURE_WEIGHT
) * 100
else:
# 降级:只有视觉维度
dup_rate = visual_sim * 100
# 全片重复计数与 check_duplicate 判定口径一致
if ev["fusion"] >= DUPLICATE_THRESHOLD and (
@@ -1190,6 +1257,110 @@ class VideoDeduplicator:
}
# ── 文案 & 结构维度查重(Issue #P2-后端3) ────────────────────────
def _normalize_text(text: str) -> str:
"""文本标准化:去空白、转小写、去标点。"""
if not text:
return ""
# 去空白字符
text = re.sub(r"\s+", "", text)
# 转小写
text = text.lower()
# 去标点(只保留中文、字母、数字)
text = re.sub(r"[^\w\u4e00-\u9fff]", "", text)
return text
def compute_text_similarity(text1: str, text2: str) -> float:
"""计算两段文本的相似度(0~1)。
使用字符级 Jaccard 相似度:交集 / 并集。
适合短文本(配音脚本)的相似度比对。
Args:
text1: 第一段文本
text2: 第二段文本
Returns:
0~1 之间的相似度
"""
t1 = _normalize_text(text1)
t2 = _normalize_text(text2)
if not t1 and not t2:
return 1.0 # 都为空,视为完全相同
if not t1 or not t2:
return 0.0 # 一个为空,完全不同
# 字符级 Jaccard
set1 = set(t1)
set2 = set(t2)
intersection = set1 & set2
union = set1 | set2
if not union:
return 0.0
return len(intersection) / len(union)
def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> float:
"""计算两个视频的结构相似度(0~1)。
结构维度包括:
1. 片段数差异(数量越接近越相似)
2. 片段类型序列(相同位置的片段类型是否一致)
3. 时长分布(各片段时长占比是否相似)
Args:
clips1: 第一个视频的片段列表,每项包含 {clip_type, duration}
clips2: 第二个视频的片段列表
Returns:
0~1 之间的相似度
"""
if not clips1 and not clips2:
return 1.0
if not clips1 or not clips2:
return 0.0
# 1. 片段数相似度(数量差异越大越低)
n1, n2 = len(clips1), len(clips2)
count_sim = min(n1, n2) / max(n1, n2)
# 2. 类型序列相似度(逐位比较,相同位置类型是否一致)
min_len = min(n1, n2)
type_matches = sum(1 for i in range(min_len) if clips1[i].get("clip_type") == clips2[i].get("clip_type"))
type_sim = type_matches / min_len if min_len > 0 else 0.0
# 3. 时长分布相似度(归一化后比较分布)
total1 = sum(c.get("duration", 0) for c in clips1)
total2 = sum(c.get("duration", 0) for c in clips2)
if total1 > 0 and total2 > 0:
# 归一化为占比
dist1 = [c.get("duration", 0) / total1 for c in clips1]
dist2 = [c.get("duration", 0) / total2 for c in clips2]
# 比较前 min_len 个片段的占比差异(L1 距离转相似度)
l1_dist = sum(abs(dist1[i] - dist2[i]) for i in range(min_len))
# 加上多出的片段占比
if n1 > n2:
l1_dist += sum(dist1[i] for i in range(n2, n1))
elif n2 > n1:
l1_dist += sum(dist2[i] for i in range(n1, n2))
# L1 距离范围 [0, 2],转为相似度 [0, 1]
duration_sim = 1.0 - (l1_dist / 2.0)
else:
duration_sim = 0.0
# 三维度加权:数量 0.3 + 类型 0.4 + 时长 0.3
return count_sim * 0.3 + type_sim * 0.4 + duration_sim * 0.3
def _save_fingerprint_chunks(
fingerprint: VideoFingerprint,
video_id: str,
@@ -2227,12 +2227,17 @@ class UnifiedRenderService:
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
if not perturbation:
return {}
return {
result = {
"hflip": bool(perturbation.get("hflip", False)),
"zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))),
"speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))),
"brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))),
}
# #1765:同时读取像素级扰动滤镜
pixel_pert = (self.plan.config or {}).get("pixel_perturbation") or {}
if pixel_pert:
result["pixel_perturbation"] = pixel_pert
return result
def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None:
# scale+pad 之前的扰动(hflip),就地修改 filters
@@ -2251,6 +2256,48 @@ class UnifiedRenderService:
if brightness != 0:
filters.append(f"eq=brightness={brightness / 100.0:.3f}")
# #1765:追加像素级扰动滤镜
pixel_pert = perturbation.get("pixel_perturbation") or {}
if pixel_pert:
self._apply_pixel_perturbation(filters, pixel_pert)
def _apply_pixel_perturbation(self, filters: list[str], pixel_pert: dict) -> None:
"""应用像素级扰动滤镜(Issue #1765)。
滤镜参数幅度确保肉眼不可见(SSIM > 0.95),但能让同素材不同变体
在帧级产生 > 3% 的差异,降低平台查重风险。
"""
filter_list = pixel_pert.get("filters") or []
for filt in filter_list:
if filt == "noise":
# 轻微噪声:noise=alls=0.015:allf=t+u
strength = pixel_pert.get("noise_strength", 0.015)
filters.append(f"noise=alls={strength}:allf=t+u")
elif filt == "unsharp":
# 锐化/柔化:unsharp=3:3:amount
# amount > 0 锐化,< 0 柔化
amount = pixel_pert.get("unsharp_amount", 0.0)
if abs(amount) > 0.01:
filters.append(f"unsharp=3:3:{amount:.2f}")
elif filt == "curves":
# 对比度微调:curves 用 preset 或手动定义
# 简单方案:用 eq=contrast 代替(curves 语法复杂)
contrast = pixel_pert.get("curves_contrast", 1.0)
if abs(contrast - 1.0) > 0.01:
filters.append(f"eq=contrast={contrast:.3f}")
elif filt == "color_balance":
# RGB 通道偏移:color_balance=rs=...:gs=...:bs=...
r = pixel_pert.get("color_r", 0)
g = pixel_pert.get("color_g", 0)
b = pixel_pert.get("color_b", 0)
if r != 0 or g != 0 or b != 0:
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
@staticmethod
def _clip_volume(clip: ResolvedClip) -> float:
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
+16 -1
View File
@@ -2126,6 +2126,14 @@
"type": "JSON",
"unique": false
},
{
"index": false,
"name": "is_default",
"nullable": false,
"primary_key": false,
"type": "BOOLEAN",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -2142,6 +2150,13 @@
],
"name": "ix_projects_owner_user_id",
"unique": false
},
{
"columns": [
"owner_user_id"
],
"name": "uq_projects_owner_default",
"unique": true
}
],
"primary_key": [
@@ -3570,4 +3585,4 @@
]
}
}
}
}
@@ -46,3 +46,21 @@ class InMemoryAssetLibraryRepository:
if library:
library.asset_count = max(0, library.asset_count - 1)
library.total_size = max(0, library.total_size - size_delta)
def get_or_create_default_library(
self,
project_id: str,
kind: AssetLibraryKind,
*,
name: str | None = None,
) -> AssetLibrary:
"""幂等获取/创建默认素材库(Issue #1775,内存实现,模拟唯一约束语义)。"""
for lib in self._libraries.values():
if lib.project_id == project_id and lib.kind == kind:
return lib
# 回退到 find_by_project
for lib in self.find_by_project(project_id, kind):
return lib
library_name = name or f"{kind.value}素材库"
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
return self.create(library)
@@ -29,3 +29,30 @@ class InMemoryProjectRepository:
del self._items[project_id]
return True
return False
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
"""查找用户的默认项目(Issue #1775 幂等接口,内存实现)。"""
for p in self._items.values():
if p.owner_user_id == owner_user_id and getattr(p, "is_default", False):
return p
return None
def get_or_create_default_project(
self,
owner_user_id: str,
*,
name: str = "默认项目",
description: str = "小程序自动创建的默认项目",
) -> Project:
"""幂等获取/创建默认项目(内存实现,模拟 DB 部分唯一索引语义)。"""
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
project = Project.create(
owner_user_id=owner_user_id,
name=name,
description=description,
is_default=True,
)
self._items[project.id] = project
return project
@@ -90,3 +90,75 @@ class SQLAlchemyAssetLibraryRepository:
model.asset_count = max(0, (model.asset_count or 0) - 1)
model.total_size = max(0, (model.total_size or 0) - size_delta)
self.session.commit()
def get_or_create_default_library(
self,
project_id: str,
kind: AssetLibraryKind,
*,
name: str | None = None,
) -> AssetLibrary:
"""幂等获取/创建项目下指定 kind 的默认素材库(Issue #1775)。
依赖唯一约束 uq_asset_libraries_project_kind(project_id, kind):
并发创建只有一个成功,其余 IntegrityError 后回滚重查,
保证同一项目同 kind 永远只有一个素材库。
"""
from sqlalchemy.exc import IntegrityError
default_names = {
AssetLibraryKind.VIDEO: "视频素材库",
AssetLibraryKind.VOICE: "配音素材库",
AssetLibraryKind.IMAGE: "图片素材库",
}
library_name = name or default_names.get(kind, f"{kind.value}素材库")
# 快速路径
existing = (
self.session.query(AssetLibraryModel)
.filter(AssetLibraryModel.project_id == project_id, AssetLibraryModel.kind == kind.value)
.first()
)
if existing:
return self._to_entity(existing)
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
model = AssetLibraryModel(
id=library.id,
project_id=library.project_id,
name=library.name,
kind=library.kind.value,
asset_count=0,
total_size=0,
created_at=library.created_at,
updated_at=library.updated_at,
)
try:
self.session.add(model)
self.session.commit()
return library
except IntegrityError:
self.session.rollback()
existing = (
self.session.query(AssetLibraryModel)
.filter(
AssetLibraryModel.project_id == project_id,
AssetLibraryModel.kind == kind.value,
)
.first()
)
if existing:
return self._to_entity(existing)
raise
def _to_entity(self, model: AssetLibraryModel) -> AssetLibrary:
return AssetLibrary(
id=model.id,
project_id=model.project_id,
name=model.name,
kind=AssetLibraryKind(model.kind),
asset_count=int(model.asset_count or 0),
total_size=int(model.total_size or 0),
created_at=model.created_at,
updated_at=model.updated_at,
)
+7 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import declarative_base
Base: Any = declarative_base()
@@ -44,12 +44,18 @@ class UserModel(Base):
class ProjectModel(Base):
__tablename__ = "projects"
__table_args__ = (
# Issue #1775: 每个用户至多一个默认项目(部分唯一索引,只约束 is_default=true 的行)。
# 注意:不加 UniqueConstraint(那会要求全表唯一),用部分索引表达"每用户一个默认项目"。
Index("uq_projects_owner_default", "owner_user_id", unique=True, postgresql_where=text("is_default = true")),
)
id = Column(String(36), primary_key=True)
owner_user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=False, default="")
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
is_default = Column(Boolean, nullable=False, default=False, server_default="false")
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -15,6 +15,7 @@ class SQLAlchemyProjectRepository:
name=model.name,
description=model.description,
shared_users=model.shared_users or [],
is_default=bool(getattr(model, "is_default", False)),
created_at=model.created_at,
)
@@ -33,9 +34,12 @@ class SQLAlchemyProjectRepository:
name=project.name,
description=project.description,
shared_users=project.shared_users,
is_default=project.is_default,
created_at=project.created_at,
)
self.session.add(model)
if existing:
existing.is_default = project.is_default
self.session.commit()
return project
@@ -76,3 +80,59 @@ class SQLAlchemyProjectRepository:
self.session.delete(model)
self.session.commit()
return True
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
"""查找用户的默认项目(is_default=true)。"""
model = (
self.session.query(ProjectModel)
.filter(ProjectModel.owner_user_id == owner_user_id, ProjectModel.is_default.is_(True))
.first()
)
return self._to_entity(model) if model else None
def get_or_create_default_project(
self,
owner_user_id: str,
*,
name: str = "默认项目",
description: str = "小程序自动创建的默认项目",
) -> Project:
"""幂等获取/创建用户的默认项目(Issue #1775)。
依赖部分唯一索引 uq_projects_owner_default(每用户至多一条 is_default=true):
并发创建时只有一个 INSERT 成功,其余触发 IntegrityError 后回滚重查,
保证同一用户永远只有一个默认项目。
"""
from sqlalchemy.exc import IntegrityError
# 快速路径:已有默认项目
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
project = Project.create(
owner_user_id=owner_user_id,
name=name,
description=description,
is_default=True,
)
model = ProjectModel(
id=project.id,
owner_user_id=project.owner_user_id,
name=project.name,
description=project.description,
shared_users=project.shared_users,
is_default=True,
created_at=project.created_at,
)
try:
self.session.add(model)
self.session.commit()
return project
except IntegrityError:
# 并发:另一个请求已插入默认项目,回滚后重查
self.session.rollback()
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
raise
@@ -6,7 +6,10 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
TemplateModel,
)
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
@@ -38,6 +41,24 @@ class SQLAlchemyTemplateClipConfigRepository:
models = query.offset(skip).limit(limit).all()
return [self._model_to_entity(m) for m in models]
def template_owned_by(self, template_id: str, user_id: str) -> bool:
"""校验旧模板主表 ``templates`` 中模板归属当前用户且未删除(is_active=True).
片段配置主表 ``template_clip_configs`` 本身没有 user_id 列,
归属关系通过模板主表 ``templates.user_id`` 确定。
新表 ``edit_templates`` 为全局模板库(无 user_id 列),不走此校验。
"""
return (
self.session.query(TemplateModel.id)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.first()
is not None
)
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
"""根据 ID 获取配置"""
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
@@ -10,6 +10,7 @@ from __future__ import annotations
import uuid
from typing import List, Optional
from sqlalchemy import or_
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
@@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository:
def __init__(self, session: Session) -> None:
self.session = session
def _filter_with_segment_configs(self, query):
"""只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。
两张表都没有记录的模板无法用于生成(from-assets 会 400),
剪辑页选模板时应排除;模板编辑器不传 valid_only,仍可见全部模板。
"""
has_clip_config = self.session.query(TemplateClipConfigModel.id).filter(
TemplateClipConfigModel.template_id == TemplateModel.id,
)
has_segment = self.session.query(TemplateSegmentModel.id).filter(
TemplateSegmentModel.template_id == TemplateModel.id,
)
return query.filter(or_(has_clip_config.exists(), has_segment.exists()))
# ── Template CRUD ──
def list_by_user(
@@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository:
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> List[Template]:
query = self.session.query(TemplateModel).filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
if valid_only:
query = self._filter_with_segment_configs(query)
if category:
query = query.filter(TemplateModel.category == category)
if mode:
@@ -102,6 +120,27 @@ class SQLAlchemyTemplateRepository:
template.segments = self.list_segments(template.id)
return template
def get_active(self, template_id: str, user_id: str) -> Optional[Template]:
"""获取归属当前用户且未删除(is_active=True)的模板,否则返回 None.
用于编辑器访问门禁:模板不存在、已软删除或不属于当前用户时返回 None,
由调用方映射为 404。与 :meth:`get` 的区别是额外过滤 is_active。
"""
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.first()
)
if model is None:
return None
template = self._model_to_entity(model)
template.segments = self.list_segments(template.id)
return template
def create(self, template: Template) -> Template:
model = TemplateModel(
id=template.id,
@@ -173,11 +212,14 @@ class SQLAlchemyTemplateRepository:
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> int:
query = self.session.query(TemplateModel).filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
if valid_only:
query = self._filter_with_segment_configs(query)
if category:
query = query.filter(TemplateModel.category == category)
if mode:
@@ -62,6 +62,7 @@ class ListTemplatesFilter:
tag: Optional[str] = None
keyword: Optional[str] = None
mode: Optional[str] = None
valid_only: bool = False
@dataclass
@@ -106,6 +106,7 @@ class ListTemplatesUseCase:
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
valid_only=filter.valid_only,
)
@@ -127,6 +128,7 @@ class CountTemplatesUseCase:
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
valid_only=filter.valid_only,
)
+3 -1
View File
@@ -70,10 +70,11 @@ class Project:
name: str
description: str = ""
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
is_default: bool = False # 是否为用户的默认项目(小程序自动创建),DB 部分唯一索引保证每人至多一个
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project":
def create(cls, owner_user_id: str, name: str, description: str = "", is_default: bool = False) -> "Project":
clean_name = name.strip()
if not clean_name:
raise ValueError("项目名称不能为空")
@@ -83,6 +84,7 @@ class Project:
name=clean_name,
description=description.strip(),
shared_users=[],
is_default=is_default,
)
def is_owner(self, user_id: str) -> bool:
+59 -2
View File
@@ -129,7 +129,10 @@ def reselect_clips_for_variant(
Raises:
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
if not source_clips:
raise ValueError("源 plan 无片段,无法为变体重新选片")
if not candidate_asset_ids:
@@ -312,7 +315,10 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
- speed_factor: 0.95~1.05 速度微调(±5%,肉眼不太敏感但时间轴不同)
- brightness_shift: -10~+10 亮度偏移(eq=brightness,画面明暗差异)
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
return {
"hflip": rng.random() < 0.3,
"zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4),
@@ -321,6 +327,57 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
}
def generate_pixel_perturbation(rng: random.Random | int | None = None) -> dict:
"""为一个变体生成像素级扰动滤镜参数(Issue #1765)。
在现有视觉扰动(hflip/zoom/brightness)基础上,额外叠加 2-3 种
像素级滤镜,让同素材不同变体在帧级 SSIM 差异 > 3%,肉眼看不出差异。
滤镜选项(随机选 2-3 种叠加):
- noise: 轻微噪声 (noise=alls=0.015:allf=t+u)
- unsharp: 锐化或柔化 (unsharp=3:3:-0.5 ~ 3:3:0.5)
- curves: 对比度微调 (curves 轻微调整)
- color_balance: RGB 通道偏移 (color_balance 微调)
返回 dict,可直接存入 plan.config["pixel_perturbation"]。
渲染侧读取后追加到 ffmpeg filter chain。
"""
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
# 可用滤镜池
filter_options = ["noise", "unsharp", "curves", "color_balance"]
# 随机选 2-3 种
num_filters = rng.choice([2, 2, 3])
selected = rng.sample(filter_options, num_filters)
result: dict = {"filters": selected}
# 为每种滤镜生成具体参数
if "noise" in selected:
# 噪声强度 0.01~0.02(肉眼不可见)
result["noise_strength"] = round(rng.uniform(0.01, 0.02), 4)
if "unsharp" in selected:
# 锐化/柔化:-0.5 ~ +0.5(正值锐化,负值柔化)
result["unsharp_amount"] = round(rng.uniform(-0.5, 0.5), 2)
if "curves" in selected:
# 对比度微调:0.95 ~ 1.05
result["curves_contrast"] = round(rng.uniform(0.95, 1.05), 3)
if "color_balance" in selected:
# RGB 通道偏移:-5 ~ +5(极轻微色偏)
result["color_r"] = rng.choice([-5, -3, 0, 0, 3, 5])
result["color_g"] = rng.choice([-5, -3, 0, 0, 3, 5])
result["color_b"] = rng.choice([-5, -3, 0, 0, 3, 5])
return result
def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict:
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
return {
+100 -6
View File
@@ -1,4 +1,4 @@
"""配音时长 → 片段时长分配纯函数(#1749)。
"""配音时长 → 片段时长分配纯函数(#1749 + #1764 节奏模板)。
定稿规则(工单 #1749):
1. 片段数 = 模板片段数,定死,不因素材增减;
@@ -8,6 +8,12 @@
禁止慢放、禁止截断配音;
4. 任何情况下不得因素材时长/数量报错打断用户。
#1764 节奏模板:
- 预设 6 种权重序列,不同变体用不同节奏模板
- 片段时长 = 配音总时长 × 该片段权重 / 权重总和
- 平均分配作为权重全 1 的特例保留
- 每个片段 >= MIN_CLIP_DURATION(2秒)
本模块为纯函数:输入片段骨架(每段转场效果/时长)与配音总时长,
输出每段目标时长(target duration)与成片总时长。不碰 DB、不碰素材。
"""
@@ -15,10 +21,72 @@
from __future__ import annotations
import logging
import random
from typing import Optional
logger = logging.getLogger(__name__)
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
MIN_CLIP_DURATION = 2.0
#: 成片总时长与配音时长的可接受误差(秒)
TOTAL_DURATION_TOLERANCE = 0.5
# ── #1764 节奏模板池 ──────────────────────────────────────────────────────
# 每种模板是权重序列,权重值代表相对时长比例
# 变体基于 variant_seed 随机选一个模板,实现不同变体时长结构不同
RHYTHM_TEMPLATES: list[list[int]] = [
[1, 1, 1, 1, 1], # 平均(基准)
[2, 1, 3, 1, 2], # 中间长,两端短
[1, 2, 1, 2, 1], # 偶数段长
[3, 1, 1, 1, 3], # 两端长,中间短
[1, 1, 3, 2, 1], # 后段渐长
[2, 1, 1, 3, 1], # 前段较长 + 第4段最长
]
def get_rhythm_template(variant_seed: int | None = None) -> list[int]:
"""根据 variant_seed 选择一个节奏模板。
Args:
variant_seed: 变体随机种子;None 时返回平均模板
Returns:
权重序列(list[int])
"""
if variant_seed is None:
return RHYTHM_TEMPLATES[0] # 默认平均
rng = random.Random(variant_seed)
return rng.choice(RHYTHM_TEMPLATES)
def adapt_template_length(template: list[int], clip_count: int) -> list[int]:
"""将节奏模板适配到实际片段数。
片段数 != 模板长度时:
- clip_count < len(template): 截断
- clip_count > len(template): 循环填充
Args:
template: 原始权重序列
clip_count: 实际片段数
Returns:
适配后的权重序列(长度 == clip_count)
"""
if clip_count <= 0:
return []
if clip_count == len(template):
return template[:]
if clip_count < len(template):
return template[:clip_count]
# clip_count > len(template): 循环填充
result = []
for i in range(clip_count):
result.append(template[i % len(template)])
return result
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
MIN_CLIP_DURATION = 1.0
@@ -43,9 +111,12 @@ def plan_clip_durations(
voice_duration: float,
transition_effects: Optional[list[Optional[str]]] = None,
transition_durations: Optional[list[float]] = None,
rhythm_template: Optional[list[int]] = None,
) -> list[float]:
"""把配音总时长分配到 clip_count 段,返回每段目标时长(秒)。
#1764:支持节奏模板,按权重比例分配时长;无模板时平均分配(向后兼容)。
分配口径:Σ段长 − Σ转场重叠 = 配音时长(成片净时长 = 配音)。
转场重叠发生在相邻片段之间,共 clip_count-1 处;第 i 处重叠取
**后一段(i+1)** 的转场设置(与 xfade 构建口径一致:转场挂在后段)。
@@ -92,13 +163,36 @@ def plan_clip_durations(
MIN_CLIP_DURATION,
)
per_clip = gross / clip_count
result = [round(per_clip, 3) for _ in range(clip_count)]
# 末段吸收舍入误差:直接用 gross - 前段之和
result[-1] = round(gross - sum(result[:-1]), 3)
# #1764:按节奏模板权重分配(无模板时全 1 = 平均分配)
weights = rhythm_template if rhythm_template and len(rhythm_template) == clip_count else [1] * clip_count
# 确保每个片段 >= MIN_CLIP_DURATION
# 先按权重分配,再检查最小值
total_weight = sum(weights)
raw_durations = [(w / total_weight) * gross for w in weights]
# 保底检查:如果有片段 < MIN_CLIP_DURATION,提升它并从最长片段扣
result = [round(d, 3) for d in raw_durations]
for _ in range(3): # 最多迭代 3 次
min_idx = min(range(len(result)), key=lambda i: result[i])
if result[min_idx] >= MIN_CLIP_DURATION:
break
# 从最长片段借时长
max_idx = max(range(len(result)), key=lambda i: result[i])
if max_idx == min_idx or result[max_idx] <= MIN_CLIP_DURATION:
# 无法再调整,强制保底
result[min_idx] = MIN_CLIP_DURATION
break
deficit = MIN_CLIP_DURATION - result[min_idx]
result[min_idx] = MIN_CLIP_DURATION
result[max_idx] = round(result[max_idx] - deficit, 3)
# 末段吸收舍入误差
total_assigned = sum(result[:-1])
result[-1] = round(gross - total_assigned, 3)
if result[-1] < MIN_CLIP_DURATION:
# 极端情况下末段被舍入压得过小,摊平
result[-1] = MIN_CLIP_DURATION
return result
+2
View File
@@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol):
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> List[Template]: ...
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
def create(self, template: Template) -> Template: ...
@@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol):
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> int: ...
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ...
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
+207
View File
@@ -0,0 +1,207 @@
"""Tests for enhanced dedup: text + structure dimensions (Issue #P2-后端3)."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# Mock heavy deps before importing dedup module
# ---------------------------------------------------------------------------
_ORIGINAL_MODULES = dict(sys.modules)
_MOCKED_MODULE_NAMES: list[str] = []
def _mock_if_absent(name: str, mock_obj=None):
"""仅在模块不在 sys.modules 中时注入 mock,并记录以便清理。"""
if name not in sys.modules:
sys.modules[name] = mock_obj if mock_obj is not None else MagicMock()
_MOCKED_MODULE_NAMES.append(name)
# Mock heavy deps
_mock_if_absent("ffmpeg")
_mock_if_absent("ffmpeg.utils")
_mock_if_absent("worker_app.celery_app")
_mock_if_absent("worker_app.db")
_mock_if_absent("packages.adapters.sqlalchemy_impl.generated_video_repository")
_mock_if_absent("packages.adapters.sqlalchemy_impl.models")
_mock_if_absent("packages.shared.storage")
# Mock cv2 and numpy if not available
try:
import cv2 as _cv2
if not isinstance(_cv2, MagicMock):
_HAS_CV2 = True
else:
_HAS_CV2 = False
except ImportError:
_HAS_CV2 = False
_mock_if_absent("cv2")
_mock_if_absent("numpy")
import pytest
from apps.worker.video_processing.dedup import (
STRUCTURE_WEIGHT,
TEXT_WEIGHT,
VISUAL_WEIGHT,
compute_structure_similarity,
compute_text_similarity,
)
class TestTextSimilarity:
"""Tests for compute_text_similarity."""
def test_identical_texts(self):
"""相同文本返回 1.0。"""
assert compute_text_similarity("你好世界", "你好世界") == 1.0
def test_empty_texts(self):
"""都为空返回 1.0。"""
assert compute_text_similarity("", "") == 1.0
def test_one_empty(self):
"""一个为空返回 0.0。"""
assert compute_text_similarity("你好", "") == 0.0
assert compute_text_similarity("", "你好") == 0.0
def test_completely_different(self):
"""完全不同文本返回低相似度。"""
sim = compute_text_similarity("你好世界", "abcdefgh")
assert sim < 0.3
def test_partial_overlap(self):
"""部分重叠文本返回中等相似度。"""
sim = compute_text_similarity("今天天气真好", "今天天气不错")
assert 0.3 < sim < 0.9
def test_case_insensitive(self):
"""英文大小写不敏感。"""
sim = compute_text_similarity("Hello World", "hello world")
assert sim == 1.0
def test_whitespace_ignored(self):
"""空白字符被忽略。"""
sim = compute_text_similarity("你好 世界", "你好世界")
assert sim == 1.0
def test_punctuation_ignored(self):
"""标点符号被忽略。"""
sim = compute_text_similarity("你好,世界!", "你好世界")
assert sim == 1.0
def test_long_texts(self):
"""长文本也能计算。"""
t1 = "这是一段很长的配音文本,用于测试文案查重功能"
t2 = "这是一段较长的配音文字,用于测试文案去重功能"
sim = compute_text_similarity(t1, t2)
assert 0.0 <= sim <= 1.0
class TestStructureSimilarity:
"""Tests for compute_structure_similarity."""
def test_identical_structures(self):
"""完全相同结构返回 1.0。"""
clips = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
{"clip_type": "video", "duration": 8.0},
]
assert compute_structure_similarity(clips, clips) == 1.0
def test_empty_clips(self):
"""都为空返回 1.0。"""
assert compute_structure_similarity([], []) == 1.0
def test_one_empty(self):
"""一个为空返回 0.0。"""
clips = [{"clip_type": "video", "duration": 5.0}]
assert compute_structure_similarity(clips, []) == 0.0
assert compute_structure_similarity([], clips) == 0.0
def test_different_count(self):
"""片段数不同,相似度降低。"""
clips1 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
]
clips2 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
{"clip_type": "video", "duration": 3.0},
{"clip_type": "title", "duration": 1.0},
]
sim = compute_structure_similarity(clips1, clips2)
assert 0.0 < sim < 0.8
def test_different_types(self):
"""片段类型不同,类型相似度低。"""
clips1 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "video", "duration": 3.0},
]
clips2 = [
{"clip_type": "title", "duration": 5.0},
{"clip_type": "title", "duration": 3.0},
]
sim = compute_structure_similarity(clips1, clips2)
assert sim <= 0.6 # 类型全部不同,但数量和时长相同贡献 0.6
def test_different_duration_distribution(self):
"""时长分布不同,时长相似度低。"""
clips1 = [
{"clip_type": "video", "duration": 10.0}, # 占比 80%
{"clip_type": "title", "duration": 2.5}, # 占比 20%
]
clips2 = [
{"clip_type": "video", "duration": 2.0}, # 占比 20%
{"clip_type": "title", "duration": 8.0}, # 占比 80%
]
sim = compute_structure_similarity(clips1, clips2)
assert 0.7 < sim < 0.9 # 类型相同但时长分布不同,sim=0.82
def test_similar_structure(self):
"""相似结构返回较高相似度。"""
clips1 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
{"clip_type": "video", "duration": 8.0},
]
clips2 = [
{"clip_type": "video", "duration": 5.5},
{"clip_type": "title", "duration": 2.2},
{"clip_type": "video", "duration": 7.5},
]
sim = compute_structure_similarity(clips1, clips2)
assert sim > 0.8
def test_single_clip(self):
"""单片段也能计算。"""
clips1 = [{"clip_type": "video", "duration": 10.0}]
clips2 = [{"clip_type": "video", "duration": 12.0}]
sim = compute_structure_similarity(clips1, clips2)
assert sim > 0.5 # 类型相同,数量相同,只是时长不同
class TestDimensionWeights:
"""Tests for dimension weight constants."""
def test_weights_sum_to_one(self):
"""多维度权重之和为 1.0。"""
assert abs(VISUAL_WEIGHT + TEXT_WEIGHT + STRUCTURE_WEIGHT - 1.0) < 1e-9
def test_visual_weight_is_half(self):
"""视觉权重为 0.5。"""
assert VISUAL_WEIGHT == 0.5
def test_text_weight_is_quarter(self):
"""文案权重为 0.25。"""
assert TEXT_WEIGHT == 0.25
def test_structure_weight_is_quarter(self):
"""结构权重为 0.25。"""
assert STRUCTURE_WEIGHT == 0.25
@@ -0,0 +1,197 @@
"""默认项目/默认素材库幂等化测试(Issue #1775)。
覆盖:
- get_or_create_default_project:同用户幂等、不同用户独立、并发只建一个
- get_or_create_default_library:同项目同 kind 幂等、IntegrityError 后重查
- Project.is_default 字段传递
- ensure-default-context 组合逻辑(用内存仓储)
"""
from __future__ import annotations
import threading
from unittest.mock import MagicMock
import pytest
from packages.adapters.in_memory.asset_library_repository import InMemoryAssetLibraryRepository
from packages.adapters.in_memory.project_repository import InMemoryProjectRepository
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.project_repository import SQLAlchemyProjectRepository
from packages.domain import AssetLibrary, AssetLibraryKind, Project
class TestDefaultProjectIdempotent:
"""默认项目幂等。"""
def test_first_call_creates_default(self):
repo = InMemoryProjectRepository()
project = repo.get_or_create_default_project("user-1")
assert project.owner_user_id == "user-1"
assert project.is_default is True
assert project.name == "默认项目"
def test_second_call_returns_same_project(self):
repo = InMemoryProjectRepository()
p1 = repo.get_or_create_default_project("user-1")
p2 = repo.get_or_create_default_project("user-1")
assert p1.id == p2.id
def test_different_users_independent(self):
repo = InMemoryProjectRepository()
p1 = repo.get_or_create_default_project("user-1")
p2 = repo.get_or_create_default_project("user-2")
assert p1.id != p2.id
assert p1.owner_user_id == "user-1"
assert p2.owner_user_id == "user-2"
def test_find_default_by_owner(self):
repo = InMemoryProjectRepository()
created = repo.get_or_create_default_project("user-1")
found = repo.find_default_by_owner("user-1")
assert found is not None
assert found.id == created.id
def test_find_default_returns_none_when_no_default(self):
repo = InMemoryProjectRepository()
# 手动建一个非默认项目
normal = Project.create(owner_user_id="user-1", name="普通项目")
normal.is_default = False
repo.save(normal)
assert repo.find_default_by_owner("user-1") is None
def test_concurrent_10_calls_only_one_project(self):
"""并发 10 次调用,只产生 1 个默认项目。"""
repo = InMemoryProjectRepository()
results = []
lock = threading.Lock()
def call():
p = repo.get_or_create_default_project("user-concurrent")
with lock:
results.append(p.id)
threads = [threading.Thread(target=call) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results) == 10
assert len(set(results)) == 1, f"应只有 1 个项目,实际: {set(results)}"
# 数据库中也只有 1 个默认项目
defaults = [p for p in repo.find_by_owner_user_id("user-concurrent") if p.is_default]
assert len(defaults) == 1
class TestDefaultLibraryIdempotent:
"""默认素材库幂等。"""
def test_first_call_creates(self):
repo = InMemoryAssetLibraryRepository()
lib = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
assert lib.project_id == "proj-1"
assert lib.kind == AssetLibraryKind.VIDEO
def test_second_call_returns_same(self):
repo = InMemoryAssetLibraryRepository()
l1 = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
l2 = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
assert l1.id == l2.id
def test_different_kinds_independent(self):
repo = InMemoryAssetLibraryRepository()
v = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
a = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VOICE)
i = repo.get_or_create_default_library("proj-1", AssetLibraryKind.IMAGE)
assert len({v.id, a.id, i.id}) == 3
def test_different_projects_independent(self):
repo = InMemoryAssetLibraryRepository()
l1 = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
l2 = repo.get_or_create_default_library("proj-2", AssetLibraryKind.VIDEO)
assert l1.id != l2.id
def test_concurrent_calls_only_one_library(self):
repo = InMemoryAssetLibraryRepository()
results = []
lock = threading.Lock()
def call():
lib = repo.get_or_create_default_library("proj-cc", AssetLibraryKind.VOICE)
with lock:
results.append(lib.id)
threads = [threading.Thread(target=call) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(set(results)) == 1
class TestProjectIsDefaultField:
"""Project.is_default 字段语义。"""
def test_create_non_default_by_default(self):
p = Project.create(owner_user_id="u", name="普通项目")
assert p.is_default is False
def test_create_default(self):
p = Project.create(owner_user_id="u", name="默认项目", is_default=True)
assert p.is_default is True
class TestSqlRepoIntegrityErrorRecovery:
"""SQLAlchemy 仓储:唯一约束冲突时回滚重查,返回已有记录(不报 500)。"""
def test_project_integrity_error_returns_existing(self):
from sqlalchemy.exc import IntegrityError
session = MagicMock()
# commit 第一次抛 IntegrityError(并发冲突),回滚后查询返回已有项目
existing_model = MagicMock()
existing_model.id = "existing-id"
existing_model.owner_user_id = "user-1"
existing_model.name = "默认项目"
existing_model.description = ""
existing_model.shared_users = []
existing_model.is_default = True
existing_model.created_at = None
session.commit.side_effect = [IntegrityError("stmt", {}, Exception("dup")), None]
# 第一次 query(快速路径 find_default)返回 None;rollback 后第二次返回 existing
session.query.return_value.filter.return_value.first.side_effect = [None, existing_model]
repo = SQLAlchemyProjectRepository(session)
result = repo.get_or_create_default_project("user-1")
assert result.id == "existing-id"
session.rollback.assert_called_once()
def test_library_integrity_error_returns_existing(self):
from sqlalchemy.exc import IntegrityError
session = MagicMock()
existing_model = MagicMock()
existing_model.id = "lib-existing"
existing_model.project_id = "proj-1"
existing_model.name = "视频素材库"
existing_model.kind = "video"
existing_model.asset_count = 0
existing_model.total_size = 0
existing_model.created_at = None
existing_model.updated_at = None
session.commit.side_effect = [IntegrityError("stmt", {}, Exception("dup")), None]
session.query.return_value.filter.return_value.first.side_effect = [None, existing_model]
repo = SQLAlchemyAssetLibraryRepository(session)
result = repo.get_or_create_default_library("proj-1", AssetLibraryKind.VIDEO)
assert result.id == "lib-existing"
assert result.kind == AssetLibraryKind.VIDEO
session.rollback.assert_called_once()
+37 -4
View File
@@ -239,8 +239,8 @@ class TestEditorClipsBySegments:
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_no_segments_raises_400(self, mock_storage):
"""模板没有 segment 配置时返回 400。"""
def test_no_segments_raises_422(self, mock_storage):
"""模板存在但未配置片段时返回 422(配置错误,与 404 区分)。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
@@ -264,11 +264,44 @@ class TestEditorClipsBySegments:
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "片段配置" in exc_info.value.detail
assert exc_info.value.status_code == 422
assert "片段" in exc_info.value.detail
# 不应调用替换方法
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_template_not_found_raises_404(self, mock_storage):
"""模板不存在/已删除/无权限(服务层抛 TemplateNotFoundError)时返回 404。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from app.services.edit_template_service import TemplateNotFoundError
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with patch(
"app.api.routes.templates_editor.clips._get_template_segments",
side_effect=TemplateNotFoundError("tpl-missing"),
):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-missing",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 404
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
class TestEditorClipsDurationAndStartTime:
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
+223 -196
View File
@@ -1,13 +1,17 @@
"""_get_template_segments 回退路径测试.
"""模板片段配置读取路径测试(#1774).
验证三级回退链:
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
3. 直接查表也失败 → 回退旧模板系统(template_segments)
4. 全部失败 → 返回空列表
收敛后模板读取走单一数据源,不再有"新表抛异常→降级查旧表→再降级查 segments"
的异常控制流:
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
from-assets 流程不再 400。
- ``EditTemplateService.list_clip_configs_for_editor`` 显式判定模板归属/存在性:
1. 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True)→ 直接读
``template_clip_configs``;
2. 全局模板在新表 ``edit_templates``(无 user_id)→ 直接读 ``template_clip_configs``;
3. 两表都没有 → 抛 ``TemplateNotFoundError``(路由层映射 404)。
- ``_get_template_segments`` 仅做配置→(order, min, max) 的映射与排序,
模板存在但无配置返回空列表(路由层映射 422)。
使用真实 SQLite 内存库 + 真实仓储,验证端到端读路径不抛 ``ValueError: 模板不存在``。
"""
from __future__ import annotations
@@ -15,21 +19,198 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.templates_editor.clips import _get_template_segments
import pytest # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from packages.adapters.sqlalchemy_impl.models import ( # noqa: E402
Base,
EditTemplateModel,
TemplateClipConfigModel,
TemplateModel,
)
TEST_TEMPLATE_ID = "tmpl-orphan-001"
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
USER_ID = "user-001"
OTHER_USER_ID = "user-002"
# ---------------------------------------------------------------------------
# 真实内存 DB fixture
# ---------------------------------------------------------------------------
def _make_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def _seed_legacy_template(session, template_id: str, user_id: str, *, active: bool = True, clip_count: int = 3):
"""创建旧表 templates 模板(+ template_clip_configs 片段配置)。"""
session.add(
TemplateModel(
id=template_id,
user_id=user_id,
name=f"模板-{template_id}",
mode="one_take",
is_active=active,
)
)
for order in range(clip_count):
session.add(
TemplateClipConfigModel(
id=f"cc-{template_id}-{order}",
template_id=template_id,
clip_type="main",
order=order,
min_duration=5.0,
max_duration=8.0,
)
)
session.commit()
def _seed_global_template(session, template_id: str, *, status: str = "active", clip_count: int = 2):
"""创建新表 edit_templates 全局模板(+ template_clip_configs 片段配置)。"""
session.add(
EditTemplateModel(
id=template_id,
name=f"全局模板-{template_id}",
template_type="default",
editing_mode="one_take",
status=status,
)
)
for order in range(clip_count):
session.add(
TemplateClipConfigModel(
id=f"gcc-{template_id}-{order}",
template_id=template_id,
clip_type="main",
order=order,
min_duration=3.0,
max_duration=6.0,
)
)
session.commit()
# ---------------------------------------------------------------------------
# Service 读路径测试
# ---------------------------------------------------------------------------
class TestListClipConfigsForEditor:
"""list_clip_configs_for_editor 单一数据源 + 归属/存在性判定。"""
def test_legacy_user_template_returns_configs(self):
"""用户自建模板(templates 表 + 3 条 clip_configs)→ 正常返回,不抛异常。"""
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_legacy_template(session, "tmpl-legacy", USER_ID, clip_count=3)
svc = EditTemplateService(session)
configs = svc.list_clip_configs_for_editor("tmpl-legacy", USER_ID)
assert len(configs) == 3
assert [c.order for c in configs] == [0, 1, 2]
assert all(c.min_duration == 5.0 for c in configs)
def test_missing_template_raises_not_found(self):
"""模板不存在(两表都没有)→ TemplateNotFoundError。"""
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
session = _make_session()
svc = EditTemplateService(session)
with pytest.raises(TemplateNotFoundError):
svc.list_clip_configs_for_editor("tmpl-not-exist", USER_ID)
def test_other_users_template_raises_not_found(self):
"""他人模板(user_id 不匹配)→ TemplateNotFoundError(归属校验)。"""
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
session = _make_session()
_seed_legacy_template(session, "tmpl-owner", OTHER_USER_ID, clip_count=3)
svc = EditTemplateService(session)
with pytest.raises(TemplateNotFoundError):
svc.list_clip_configs_for_editor("tmpl-owner", USER_ID)
def test_deleted_legacy_template_raises_not_found(self):
"""已软删除(is_active=False)的旧表模板 → TemplateNotFoundError。"""
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
session = _make_session()
_seed_legacy_template(session, "tmpl-deleted", USER_ID, active=False, clip_count=3)
svc = EditTemplateService(session)
with pytest.raises(TemplateNotFoundError):
svc.list_clip_configs_for_editor("tmpl-deleted", USER_ID)
def test_legacy_template_without_configs_returns_empty(self):
"""模板存在且归属正确但无片段配置 → 返回空列表(不抛异常,路由层映射 422)。"""
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_legacy_template(session, "tmpl-noconfig", USER_ID, clip_count=0)
svc = EditTemplateService(session)
configs = svc.list_clip_configs_for_editor("tmpl-noconfig", USER_ID)
assert configs == []
def test_global_template_returns_configs(self):
"""新表 edit_templates 全局模板(无 user_id)→ 任意用户可读,正常返回。"""
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_global_template(session, "tmpl-global", clip_count=2)
svc = EditTemplateService(session)
configs = svc.list_clip_configs_for_editor("tmpl-global", USER_ID)
assert len(configs) == 2
assert [c.order for c in configs] == [0, 1]
def test_normal_legacy_request_does_not_raise_valueerror(self):
"""正常旧表模板请求绝不在读路径抛 ValueError: 模板不存在(回归保护)。"""
import logging
from app.services.edit_template_service import EditTemplateService
session = _make_session()
_seed_legacy_template(session, "tmpl-ok", USER_ID, clip_count=3)
svc = EditTemplateService(session)
with pytest.MonkeyPatch.context() as mp:
# 若读路径意外抛 ValueError 并被记录为异常堆栈,测试能感知
errors: list[str] = []
mp.setattr(
logging.getLogger("app.services.edit_template_service"),
"exception",
lambda *a, **k: errors.append(str(a)),
)
configs = svc.list_clip_configs_for_editor("tmpl-ok", USER_ID)
assert len(configs) == 3
assert errors == []
# ---------------------------------------------------------------------------
# _get_template_segments 映射测试
# ---------------------------------------------------------------------------
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
"""构造 mock TemplateClipConfig 领域实体."""
cc = MagicMock()
cc.order = order
cc.min_duration = min_dur
@@ -37,207 +218,53 @@ def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
return cc
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
"""构造 mock 旧 TemplateSegment."""
s = MagicMock()
s.segment_order = segment_order
s.duration_min = dur_min
s.duration_max = dur_max
return s
class TestGetTemplateSegments:
"""_get_template_segments 仅做映射/排序,异常与空配置语义明确。"""
def test_maps_and_sorts_configs(self):
from app.api.routes.templates_editor.clips import _get_template_segments
# ---------------------------------------------------------------------------
# 测试
# ---------------------------------------------------------------------------
class TestGetTemplateSegmentsFallback:
"""_get_template_segments 三级回退链."""
def test_new_system_works(self):
"""路径1:新模板系统正常返回 → 直接使用."""
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = configs
db = MagicMock()
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 2
assert result[0] == (0, 2.0, 6.0)
assert result[1] == (1, 3.0, 9.0)
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
def test_main_table_missing_direct_query_succeeds(self):
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
模拟自建模板在 edit_templates 主表已删除/不存在,
但 template_clip_configs 表有记录。
"""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
db = MagicMock()
# Mock SQLAlchemyTemplateClipConfigRepository
direct_configs = [
_make_clip_config(0, 2.0, 5.0),
_make_clip_config(1, 3.0, 7.0),
_make_clip_config(2, 4.0, 8.0),
]
with (
__import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls,
):
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = direct_configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 3
assert result[0] == (0, 2.0, 5.0)
assert result[1] == (1, 3.0, 7.0)
assert result[2] == (2, 4.0, 8.0)
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
def test_main_table_missing_direct_query_empty_falls_to_old(self):
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
old_segments = [_make_old_segment(0, 3.0, 6.0)]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = [] # 新表也没记录
mock_repo_cls.return_value = mock_repo
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
) as mock_old_cls:
mock_old = MagicMock()
mock_old.list_segments.return_value = old_segments
mock_old_cls.return_value = mock_old
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 1
assert result[0] == (0, 3.0, 6.0)
def test_all_fail_returns_empty(self):
"""路径4:三级全部失败 → 返回空列表."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.side_effect = Exception("DB error")
mock_repo_cls.return_value = mock_repo
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
) as mock_old_cls:
mock_old = MagicMock()
mock_old.list_segments.return_value = [] # 旧表也空
mock_old_cls.return_value = mock_old
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert result == []
def test_direct_query_sorts_by_order(self):
"""直接查表返回的结果按 order 排序."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
# 故意乱序
configs = [
tpl_svc.list_clip_configs_for_editor.return_value = [
_make_clip_config(2, 5.0, 10.0),
_make_clip_config(0, 2.0, 4.0),
_make_clip_config(1, 3.0, 6.0),
]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
assert [r[0] for r in result] == [0, 1, 2]
assert result[0] == (0, 2.0, 4.0)
assert result[1] == (1, 3.0, 6.0)
assert result[2] == (2, 5.0, 10.0)
tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", USER_ID)
def test_empty_configs_returns_empty(self):
from app.api.routes.templates_editor.clips import _get_template_segments
def test_direct_query_handles_none_durations(self):
"""直接查表时 min/max_duration 为 None → 使用默认值."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
tpl_svc.list_clip_configs_for_editor.return_value = []
assert _get_template_segments("tmpl-1", USER_ID, tpl_svc) == []
def test_missing_template_propagates_not_found(self):
from app.api.routes.templates_editor.clips import _get_template_segments
from app.services.edit_template_service import TemplateNotFoundError
tpl_svc = MagicMock()
tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
with pytest.raises(TemplateNotFoundError):
_get_template_segments("tmpl-x", USER_ID, tpl_svc)
def test_none_durations_use_default(self):
from app.api.routes.templates_editor.clips import _get_template_segments
tpl_svc = MagicMock()
cc = MagicMock()
cc.order = 0
cc.min_duration = None
cc.max_duration = None
tpl_svc.list_clip_configs_for_editor.return_value = [cc]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = [cc]
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 1
# None → default (5.0), max(None or None) → default (5.0)
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
def test_new_system_returns_empty_tries_direct(self):
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
db = MagicMock()
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = direct_configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
# 直接查表有数据 → 返回
assert len(result) == 1
assert result[0] == (0, 3.0, 6.0)
def test_existing_template_unaffected(self):
"""正常模板(主表存在)行为不变."""
configs = [_make_clip_config(0, 2.0, 5.0)]
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = configs
db = MagicMock()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
# 直接查表不应被调用(新系统已返回)
mock_repo_cls.assert_not_called()
assert len(result) == 1
assert result[0] == (0, 2.0, 5.0)
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
assert result == [(0, DEFAULT_DUR, DEFAULT_DUR)]
+60 -70
View File
@@ -226,10 +226,10 @@ class TestGetMediakitRecommendations:
class TestGetTemplateSegments:
"""测试模板片段配置查询。"""
"""测试模板片段配置查询(单一数据源:template_clip_configs)。"""
def test_returns_segments_from_new_template_system(self):
"""新模板系统(clip_configs)有数据时优先使用。"""
def test_returns_segments_from_clip_configs(self):
"""片段配置主表(clip_configs)有数据时按 order 排序返回。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
@@ -241,66 +241,34 @@ class TestGetTemplateSegments:
cc2.order = 1
cc2.min_duration = 4.0
cc2.max_duration = 8.0
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
mock_tpl_svc.list_clip_configs_for_editor.return_value = [cc2, cc1] # 乱序返回
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
assert len(result) == 2
assert result[0] == (0, 3.0, 5.0)
assert result[1] == (1, 4.0, 8.0)
mock_tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", "user-1")
def test_falls_back_to_old_template_segments(self):
"""新模板系统无数据时回退到旧系统。"""
def test_returns_empty_when_no_configs(self):
"""模板存在但没有片段配置时返回空列表(路由层据此返回 422)。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
seg1 = MagicMock()
seg1.segment_order = 0
seg1.duration_min = 2.0
seg1.duration_max = 4.0
mock_repo.list_segments.return_value = [seg1]
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
assert result == []
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 1
assert result[0] == (0, 2.0, 4.0)
def test_returns_empty_when_no_segments(self):
"""两套系统都没有片段配置时返回空列表。"""
def test_missing_template_raises(self):
"""模板不存在/无权限时服务层抛 TemplateNotFoundError(路由层据此返回 404)。"""
from app.api.routes.templates_editor.clips import _get_template_segments
from app.services.edit_template_service import TemplateNotFoundError
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
mock_repo.list_segments.return_value = []
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert result == []
def test_new_system_exception_falls_back(self):
"""新模板系统异常时回退到旧系统。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
seg = MagicMock()
seg.segment_order = 0
seg.duration_min = 1.0
seg.duration_max = 3.0
mock_repo.list_segments.return_value = [seg]
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 1
with pytest.raises(TemplateNotFoundError):
_get_template_segments("tmpl-x", "user-1", mock_tpl_svc)
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
@@ -358,7 +326,7 @@ def _make_tpl_svc_with_segments(segments):
"""segments: list of (order, min_dur, max_dur)"""
svc = MagicMock()
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
svc.list_clip_configs.return_value = clip_configs
svc.list_clip_configs_for_editor.return_value = clip_configs
return svc
@@ -540,34 +508,56 @@ class TestFromAssetsByTemplateSegments:
orders = [c["order"] for c in clips_data]
assert orders == [0, 1, 2]
def test_no_segments_raises_400(self):
"""模板没有 segment 配置时返回 400。"""
def test_no_segments_raises_422(self):
"""模板存在但未配置片段时返回 422(与模板不存在的 404 区分)。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from fastapi import HTTPException
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
mock_plan_svc = _make_plan_svc()
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
mock_repo.list_segments.return_value = []
MockRepo.return_value = mock_repo
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
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=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 422
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
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=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
def test_template_not_found_raises_404(self):
"""模板不存在/已删除/无权限时返回 404。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from app.services.edit_template_service import TemplateNotFoundError
from fastapi import HTTPException
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
mock_plan_svc = _make_plan_svc()
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-x",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 404
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
+141
View File
@@ -0,0 +1,141 @@
"""像素级扰动滤镜单元测试(Issue #1765)。
覆盖:
- generate_pixel_perturbation:生成像素级扰动参数
- 滤镜组合:2-3 种滤镜随机组合
- 参数范围:肉眼不可见但帧级可检测
- FFmpeg 滤镜语法生成
"""
from __future__ import annotations
import random
import pytest
from packages.domain.variant_plan_selector import generate_pixel_perturbation
class TestGeneratePixelPerturbation:
"""generate_pixel_perturbation 测试。"""
def test_returns_dict(self):
"""返回 dict。"""
result = generate_pixel_perturbation()
assert isinstance(result, dict)
def test_has_filters_key(self):
"""包含 filters 键。"""
result = generate_pixel_perturbation()
assert "filters" in result
def test_filters_count_2_or_3(self):
"""选 2-3 种滤镜。"""
for _ in range(50):
result = generate_pixel_perturbation()
assert len(result["filters"]) in [2, 3]
def test_filters_from_valid_options(self):
"""滤镜来自有效选项。"""
valid_options = {"noise", "unsharp", "curves", "color_balance"}
for _ in range(50):
result = generate_pixel_perturbation()
for f in result["filters"]:
assert f in valid_options
def test_noise_parameters(self):
"""noise 滤镜有正确参数范围。"""
for _ in range(20):
result = generate_pixel_perturbation()
if "noise" in result["filters"]:
strength = result.get("noise_strength", 0)
assert 0.01 <= strength <= 0.02
def test_unsharp_parameters(self):
"""unsharp 滤镜有正确参数范围。"""
for _ in range(20):
result = generate_pixel_perturbation()
if "unsharp" in result["filters"]:
amount = result.get("unsharp_amount", 0)
assert -0.5 <= amount <= 0.5
def test_curves_parameters(self):
"""curves 滤镜有正确参数范围。"""
for _ in range(20):
result = generate_pixel_perturbation()
if "curves" in result["filters"]:
contrast = result.get("curves_contrast", 1.0)
assert 0.95 <= contrast <= 1.05
def test_color_balance_parameters(self):
"""color_balance 滤镜有正确参数范围。"""
valid_colors = [-5, -3, 0, 3, 5]
for _ in range(20):
result = generate_pixel_perturbation()
if "color_balance" in result["filters"]:
assert result.get("color_r") in valid_colors
assert result.get("color_g") in valid_colors
assert result.get("color_b") in valid_colors
def test_same_seed_same_result(self):
"""相同 seed 返回相同结果。"""
rng1 = random.Random(42)
rng2 = random.Random(42)
result1 = generate_pixel_perturbation(rng1)
result2 = generate_pixel_perturbation(rng2)
assert result1 == result2
def test_different_seeds_may_differ(self):
"""不同 seed 可能返回不同结果。"""
results = set()
for seed in range(20):
rng = random.Random(seed)
result = generate_pixel_perturbation(rng)
results.add(tuple(result["filters"]))
# 20 个 seed 至少看到 3 种不同组合
assert len(results) >= 3
class TestPixelPerturbationAcceptance:
"""Issue #1765 验收测试。"""
def test_batch_3_variants_have_different_filters(self):
"""批量 3 个变体有不同的滤镜组合。"""
results = []
for seed in [100, 200, 300]:
rng = random.Random(seed)
result = generate_pixel_perturbation(rng)
results.append(tuple(result["filters"]))
# 至少 2 种不同组合
unique = len(set(results))
assert unique >= 2, f"Expected >= 2 unique filter combos, got {unique}: {results}"
class TestIntSeedSupport:
"""int seed 入参支持(与 get_rhythm_template(seed) 接口一致)。"""
def test_int_seed_returns_dict(self):
"""int seed 正常返回 dict。"""
result = generate_pixel_perturbation(42)
assert isinstance(result, dict)
assert "filters" in result
def test_int_seed_reproducible(self):
"""相同 int seed 结果一致。"""
assert generate_pixel_perturbation(42) == generate_pixel_perturbation(42)
def test_int_seed_differs_across_seeds(self):
"""不同 int seed 大概率不同(遍历确认至少 2 种组合)。"""
results = {tuple(generate_pixel_perturbation(s)["filters"]) for s in range(30)}
assert len(results) >= 2
def test_int_seed_matches_random_obj(self):
"""int seed 与等价 random.Random(seed) 结果一致。"""
assert generate_pixel_perturbation(7) == generate_pixel_perturbation(random.Random(7))
def test_none_seed_works(self):
"""None 入参(默认随机)正常返回。"""
result = generate_pixel_perturbation(None)
assert isinstance(result, dict)
assert len(result["filters"]) in [2, 3]
+190
View File
@@ -0,0 +1,190 @@
"""节奏模板单元测试(Issue #1764)。
覆盖:
- RHYTHM_TEMPLATES 池定义(6 种模板)
- get_rhythm_template:根据 seed 选择模板
- adapt_template_length:适配不同片段数
- plan_clip_durations:按权重分配时长
- 时长约束:总时长 ≈ 配音时长,每段 >= 2s
"""
from __future__ import annotations
import pytest
from packages.domain.voice_duration_planner import (
MIN_CLIP_DURATION,
RHYTHM_TEMPLATES,
adapt_template_length,
get_rhythm_template,
plan_clip_durations,
total_output_duration,
)
class TestRhythmTemplates:
"""节奏模板池测试。"""
def test_six_templates_defined(self):
"""预设 6 种节奏模板。"""
assert len(RHYTHM_TEMPLATES) == 6
def test_average_template_is_all_ones(self):
"""第一种模板是平均(全 1)。"""
assert RHYTHM_TEMPLATES[0] == [1, 1, 1, 1, 1]
def test_all_templates_have_5_elements(self):
"""所有模板长度为 5(会被 adapt 适配)。"""
for tpl in RHYTHM_TEMPLATES:
assert len(tpl) == 5
class TestGetRhythmTemplate:
"""get_rhythm_template 测试。"""
def test_none_seed_returns_average(self):
"""None seed 返回平均模板。"""
assert get_rhythm_template(None) == [1, 1, 1, 1, 1]
def test_same_seed_same_template(self):
"""相同 seed 返回相同模板。"""
tpl1 = get_rhythm_template(42)
tpl2 = get_rhythm_template(42)
assert tpl1 == tpl2
def test_different_seeds_may_differ(self):
"""不同 seed 可能返回不同模板。"""
templates_seen = set()
for seed in range(100):
tpl = tuple(get_rhythm_template(seed))
templates_seen.add(tpl)
# 100 个 seed 应该至少看到 3 种不同模板
assert len(templates_seen) >= 3
class TestAdaptTemplateLength:
"""adapt_template_length 测试。"""
def test_same_length(self):
"""片段数 == 模板长度时直接返回。"""
tpl = [2, 1, 3, 1, 2]
assert adapt_template_length(tpl, 5) == [2, 1, 3, 1, 2]
def test_shorter_clip_count(self):
"""片段数 < 模板长度时截断。"""
tpl = [2, 1, 3, 1, 2]
assert adapt_template_length(tpl, 3) == [2, 1, 3]
def test_longer_clip_count(self):
"""片段数 > 模板长度时循环填充。"""
tpl = [2, 1, 3]
result = adapt_template_length(tpl, 7)
assert result == [2, 1, 3, 2, 1, 3, 2]
def test_zero_clip_count(self):
"""片段数 0 返回空列表。"""
assert adapt_template_length([1, 2, 3], 0) == []
class TestPlanClipDurationsWithRhythm:
"""plan_clip_durations 节奏模板测试。"""
def test_average_template_equals_old_behavior(self):
"""全 1 模板 = 原来的平均分配。"""
voice = 20.0
clips = 4
result = plan_clip_durations(clips, voice, rhythm_template=[1, 1, 1, 1])
# 每段应该 ≈ 5s
assert all(abs(d - 5.0) < 0.1 for d in result)
assert abs(sum(result) - voice) < 0.1
def test_weighted_template_different_durations(self):
"""权重模板产生不同时长的片段。"""
voice = 18.0
clips = 5
# 权重 [2, 1, 3, 1, 2]:第 3 段最长,第 2/4 段最短
template = [2, 1, 3, 1, 2]
result = plan_clip_durations(clips, voice, rhythm_template=template)
# 总时长 ≈ 配音时长
assert abs(sum(result) - voice) < 0.5
# 第 3 段应该最长
assert result[2] > result[1]
assert result[2] > result[3]
def test_min_clip_duration_enforced(self):
"""每段 >= MIN_CLIP_DURATION (2s)。"""
voice = 15.0
clips = 5
# 极端权重:某段权重极低
template = [10, 1, 1, 1, 1]
result = plan_clip_durations(clips, voice, rhythm_template=template)
for d in result:
assert d >= MIN_CLIP_DURATION
def test_total_duration_with_transitions(self):
"""含转场时总时长仍然正确。"""
voice = 20.0
clips = 4
effects = [None, "xfade", "fade", "cut"]
durations = [0.0, 0.5, 0.3, 0.0]
template = [2, 1, 1, 2]
result = plan_clip_durations(
clips,
voice,
transition_effects=effects,
transition_durations=durations,
rhythm_template=template,
)
# 成片净时长 = Σ段长 - Σ转场重叠 ≈ 配音时长
output = total_output_duration(result, effects, durations)
assert abs(output - voice) < 0.5
def test_no_template_backward_compatible(self):
"""不传模板时行为与旧版一致(平均分配)。"""
voice = 16.0
clips = 4
result = plan_clip_durations(clips, voice)
assert all(abs(d - 4.0) < 0.1 for d in result)
def test_six_templates_produce_different_structures(self):
"""6 种模板产生不同的时长结构。"""
voice = 25.0
clips = 5
structures = set()
for tpl in RHYTHM_TEMPLATES:
result = plan_clip_durations(clips, voice, rhythm_template=tpl)
# 用 round 后的元组作为结构指纹
structure = tuple(round(d, 1) for d in result)
structures.add(structure)
# 至少 4 种不同结构
assert len(structures) >= 4
class TestIssue1764Acceptance:
"""Issue #1764 验收测试。"""
def test_batch_3_variants_at_least_2_different(self):
"""批量 3 个变体,至少 2 组不同片段时长序列。"""
voice = 20.0
clips = 5
# 模拟 3 个变体用不同 seed
seeds = [100, 200, 300]
structures = []
for seed in seeds:
template = get_rhythm_template(seed)
adapted = adapt_template_length(template, clips)
durations = plan_clip_durations(clips, voice, rhythm_template=adapted)
structures.append(tuple(round(d, 1) for d in durations))
# 至少 2 种不同结构
unique = len(set(structures))
assert unique >= 2, f"Expected >= 2 unique structures, got {unique}: {structures}"
+2
View File
@@ -188,6 +188,7 @@ class TestListTemplatesUseCase:
tag="tag1",
keyword="test",
mode="one_take",
valid_only=False,
)
def test_list_pagination(self):
@@ -226,6 +227,7 @@ class TestCountTemplatesUseCase:
tag="tag1",
keyword="kw",
mode="pip",
valid_only=False,
)
@@ -158,6 +158,52 @@ class TestListByUser:
assert len(result[0].segments) == 1
assert result[0].segments[0].duration_min == 2.0
def test_valid_only_filters_templates_without_segments(self, repo, session):
"""#1769: valid_only=True 时排除两张片段表都没有记录的无效模板."""
# 有效模板:有 clip_configs
valid_clip = _make_template(name="有效模板-clip_configs")
repo.create(valid_clip)
repo.create_segments([_make_segment(valid_clip.id, order=1)])
# 有效模板:仅有旧表 template_segments 记录
valid_old = _make_template(name="有效模板-old_segments")
repo.create(valid_old)
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=valid_old.id,
segment_order=1,
duration_min=2.0,
duration_max=6.0,
)
session.add(old)
session.commit()
# 无效模板:两张表都没有记录
invalid = _make_template(name="无效模板-无片段")
repo.create(invalid)
# 默认不过滤:编辑器视角能看到全部 3 个模板
all_templates = repo.list_by_user("u1")
assert len(all_templates) == 3
assert repo.count_by_user("u1") == 3
# valid_only=True:剪辑页视角只返回 2 个有效模板
valid_templates = repo.list_by_user("u1", valid_only=True)
assert {t.name for t in valid_templates} == {"有效模板-clip_configs", "有效模板-old_segments"}
assert all(len(t.segments) > 0 for t in valid_templates)
assert repo.count_by_user("u1", valid_only=True) == 2
def test_valid_only_with_filters_and_pagination(self, repo, session):
"""valid_only 与其他过滤/分页条件组合使用."""
tpl = _make_template(name="口播模板", mode="voice_over")
repo.create(tpl)
repo.create_segments([_make_segment(tpl.id, order=1, material_type="人物")])
_invalid = _make_template(name="口播无效模板", mode="voice_over")
repo.create(_invalid)
result = repo.list_by_user("u1", mode="voice_over", valid_only=True)
assert len(result) == 1
assert result[0].name == "口播模板"
assert repo.count_by_user("u1", mode="voice_over", valid_only=True) == 1
class TestCopyTemplate:
def test_copy_writes_to_clip_configs(self, repo, session):