Compare commits

...

50 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 / Check push changed paths (pull_request) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 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
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 (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / 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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / 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
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
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
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 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 (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
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 / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m13s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (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
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 / Check push changed paths (pull_request) 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 4s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 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 / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m16s
CI/CD Pipeline / Unit Tests (push) Successful in 8m6s
CI/CD Pipeline / Validate - Security (push) Successful in 16m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-07 23:01:36 +08:00
saas-backend-agent cd6d8615e6 test: #1765 补充 int seed 分支测试,diff coverage 达标
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
新增 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 / Check push changed paths (pull_request) 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 3s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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
与 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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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 / Check push changed paths (pull_request) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / 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
2026-09-07 14:03:49 +00:00
saas-backend-agent 9f85d40855 fix: 修复 compute_duplicate_rate 方法被错误嵌套到模块函数内部
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Check push changed paths (pull_request) Has been skipped
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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-07 20:56:11 +08:00
saas-backend-agent 27dccf6591 fix: #1769 智能剪辑 Step2 素材加载 400 错误
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
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
- 根因:模板 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 / Check push changed paths (pull_request) Has been skipped
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (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
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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
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
- 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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 6s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 35s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Frontend Lint (pull_request) Has been skipped
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
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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-07 12:20:53 +00:00
saas-backend-agent eb089fa26d feat: 节奏模板让批量变体片段时长分布不同 (Issue #1764)
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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
- 新增 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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-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 / Check if frontend-only change (push) Has been skipped
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 (pull_request) Has been skipped
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) 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
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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-07 11:49:16 +00:00
saas-backend-agent add93bb9de feat(dedup): 增加文案+结构维度查重 (Issue #P2-后端3)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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
- 新增 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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
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
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-09-07 19:28:22 +08:00
xiaoxia d73c0a77d8 fix: Step5 BatchGenerationGrid 单卡居中 + maxWidth 240px (#1760)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / Frontend Unit Tests (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m46s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m42s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m36s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 18:51:29 +08:00
saas-backend-agent 1087ec181b fix: test_empty_frame_returns_zero 添加 cv2 可用性检测
CI/CD Pipeline / Check push changed paths (pull_request) 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 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m17s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 9m11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m59s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 45s
2026-09-07 18:44:32 +08:00
CI Bot 04463fbdce style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m48s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m19s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m39s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 4s
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m9s
2026-09-07 10:33:42 +00:00
saas-backend-agent fadca408d4 fix: 测试添加 cv2 可用性检测,mock 环境下 skip
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) 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
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
2026-09-07 18:31:18 +08:00
saas-backend-agent c6a3137949 fix: 加强 cv2 mock 检测(用 inspect.isroutine 验证)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
2026-09-07 18:30:39 +08:00
saas-backend-agent 3447ab2582 fix: 修复 CI 测试失败
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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 (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 / 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
1. cover_frame_scorer.py: 加强 cv2/numpy 真实性检查,防止 mock 对象
2. test_generation_cover.py: 更新 max_frames 断言 1→5
2026-09-07 18:29:43 +08:00
CI Bot 37ebf42278 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m33s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
2026-09-07 10:21:04 +00:00
saas-backend-agent d6a2715a26 feat: 封面智能选帧 — 帧质量评分(清晰度/亮度/色彩)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 2m29s
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 / Integration Tests (pull_request) Successful in 2m29s
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
P1-后端2:封面智能选帧

新增 cover_frame_scorer.py:
- score_frame(): 评估单帧质量 0~100 分
  - 清晰度 (0~40): 拉普拉斯方差,越高越清晰
  - 亮度 (0~30): 80~180 舒适区间满分
  - 色彩丰富度 (0~30): RGB 三维直方图非零 bin
- score_frames(): 批量评分+排序
- select_best_frame(): 选出最佳帧
- 降级策略:cv2 不可用时返回 50.0 默认分

修改 thumbnail_generator.py:
- extract_and_upload_cover_frames: num_frames=3→5
- 抽帧后对所有候选帧进行质量评分
- 按分数从高到低排序返回
- 统一临时文件管理(评分后清理)

修改 generation_cover.py:
- E1/E2 步骤 max_frames=1→5
- 新增 _select_best_frame_from_snapshots() 辅助函数
- MediaKit 抽多帧后通过质量评分选最佳

修改 render_adapter.py:
- 调用 extract_and_upload_cover_frames 时 num_frames=5

测试:test_cover_frame_scorer.py(18 个测试)
2026-09-07 18:18:19 +08:00
xiaoxia b8073b6c38 fix: Step5卡片黑边+缩放 & 配音时长对齐纳入转场时长 (#1756) (#1758)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m17s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 27s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m4s
CI/CD Pipeline / Validate - Style (push) Successful in 2m16s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m22s
CI/CD Pipeline / Validate - Security (push) Successful in 6m49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m25s
CI/CD Pipeline / Unit Tests (push) Successful in 7m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-07 16:39:24 +08:00
xiaoxia debb3a5d67 feat(#1758): FFmpeg 渲染加速——preset medium→fast + 多线程 + 环境变量可覆盖 (#1757)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
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 (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
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 Web 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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 16:37:35 +08:00
xiaoxia 87a61b7f2b fix(#1754): ASS 标题位置常量同步前端改动(TOP 120→180, BOTTOM 60→100) (#1756)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m55s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Validate - Security (push) Successful in 5m55s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m49s
CI/CD Pipeline / Unit Tests (push) Successful in 7m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m19s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m55s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 14:39:27 +08:00
xiaoxia 4b3feb2d88 feat(#1754): 渲染期画面扰动——让批量视频本身更不同 (#1755)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 12s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m34s
CI/CD Pipeline / Validate - Style (push) Successful in 2m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Validate - Security (push) Successful in 6m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m55s
CI/CD Pipeline / Unit Tests (push) Successful in 6m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 13:55:47 +08:00
xiaoxia d8da69970b fix(generate): #1754 预览卡片黑边+占比过大/配音时长兜底调速/标题位置优化 (#1754)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 58s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 33s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m13s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
CI/CD Pipeline / Validate - Style (push) Successful in 2m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 13:52:23 +08:00
xiaoxia 3557635f93 fix(#1749): variant-plans clips 补 status 字段(P0 批量预览过滤修复) (#1753)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m27s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Validate - Security (push) Successful in 6m25s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Unit Tests (push) Successful in 7m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 20m25s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 11:08:46 +08:00
xiaoxia e83a0b86f8 fix(#1749): 批量三连修:配音严格守卫/配音时长分配+末帧冻结/素材级去重 + variant-plans 接口 (#1752)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Unit Tests (push) Successful in 8m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m48s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 09:22:34 +08:00
xiaoxia 478c9c2bc3 fix(generate): #1750 批量预览五连修——删除假数据 fallback/配音时长/竖屏播放器/封面一键生成 (#1751)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 27s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
CI/CD Pipeline / Validate - Style (push) Successful in 2m14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Security (push) Successful in 5m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Unit Tests (push) Failing after 7m50s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 7m15s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 01:28:34 +08:00
xiaoxia bcbcb41750 feat(generate): #1744 批量预览对接后端变体真实片段 + 智能匹配候选池×3 (#1748)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m27s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Successful in 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m13s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m34s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Failing after 8m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m11s
CI/CD Pipeline / Validate - Security (push) Successful in 18m6s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 20:45:10 +08:00
xiaoxia 4c03e05e6c fix(#1743): smart-match排序随机噪声 + 素材使用次数按成片实际片段计数 (#1747)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
AI Code Review / AI Code Review (pull_request) Failing after 3m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m44s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Unit Tests (push) Failing after 9m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m27s
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 50s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Security (push) Successful in 19m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-06 20:17:07 +08:00
saas-backend-agent 6cdb70bb61 test(#1743): 修复 smart-match 随机噪声引发的确定性排序断言
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
AI Code Review / AI Code Review (pull_request) Successful in 1m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 14m41s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m30s
CI Unit Tests 暴露(沙箱 httpx2 无法 collect test_smart_match*.py 未本地复现):
- test_smart_match_fallback test_limit_backfills: second(quality=40)/third(30)
  质量项仅差 4 分 < 噪声 20,third 可翻到首位 → second 质量提至 90(差 24>20),
  200 seeds 验证零翻转
- test_smart_match test_returns_sorted_by_score_descending / test_image_assets:
  smart_select_assets 直接调用处注入零噪声 rng(_ZeroNoiseRandom),验证纯评分排序
- 验证生产噪声行为:大分差素材排名稳定、同分素材组合随机
2026-09-06 19:58:59 +08:00
saas-backend-agent ed09794f4d fix(#1743): smart-match排序注入随机噪声 + 素材使用次数按成片实际片段计数回写
CI/CD Pipeline / Check push changed paths (pull_request) 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 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m25s
AI Code Review / AI Code Review (pull_request) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
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
1. smart-match 排序零随机修复(主因):
   - smart_select_assets 排序/多样性分桶注入 0~SCORE_RANDOM_NOISE_MAX 随机噪声,
     同分/近分素材每次选出不同组合与顺序;分差>20的高质量素材保持稳定优先级
   - 噪声以 asset.id 为 key 同次调用内一致;r.score 始终为无噪声原始分
   - 支持 rng 注入(测试可复现);smart-match API/正式生成/模板编辑器三调用点全受益
2. 素材使用次数口径修复:
   - mark_asset_used_for_generation 新增 times 参数,按成片实际渲染片段引用次数累加
   - worker 回写从 task.asset_ids(请求列表,含未被plan选用的素材)改为
     统计最终成片 plan 的 edit_plan_clips(同素材多片段复用按片段数累加)
   - 抽 _count_plan_clip_asset_usage/_record_rendered_asset_usage 纯函数(可单测)
   - plan 无有效片段时兜底 task.asset_ids 单次计数;单素材失败不阻断其他
3. 测试:22 新测试(噪声 10 + 回写计数 12);旧确定性排序断言注入零噪声 rng;
   修复 test_distribute_assets 预存在 flaky(shuffle 未被零噪声 patch 覆盖)
2026-09-06 19:48:08 +08:00
xiaoxia 52664f7168 fix(auth): 默认昵称改小虾同学 + 登录不覆盖存量昵称 + 中文昵称链路守护 (#1746)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Integration Tests (push) Successful in 2m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / Validate - Style (push) Successful in 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 4m16s
CI/CD Pipeline / Validate - Security (push) Successful in 4m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m49s
CI/CD Pipeline / Unit Tests (push) Failing after 7m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 18:23:55 +08:00
xiaoxia 0469272bd6 fix(#1743): 批量变体独立选片——完整重跑单视频选片+批次20%重叠避让+查重超阈重渲+封面独立 (#1745)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Validate - Security (push) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Failing after 5m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 18:07:24 +08:00
xiaoxia 6638f8b29e feat(#1741): 批量预览卡片缩小至3/5 + 每个视频播放都有声音(配音全挂载+播放互斥+静音按钮) (#1742)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 2m13s
AI Code Review / AI Code Review (pull_request) Failing after 5m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m32s
CI/CD Pipeline / Validate - Security (push) Successful in 5m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m51s
CI/CD Pipeline / Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 22m33s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 15:41:36 +08:00
xiaoxia 794793f992 Merge pull request 'fix(auth): 微信 unionid 账号打通逻辑修复(staging 部署版,含 profile_completed)' (#1740) from fix/wechat-unionid-linking-develop into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m3s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m10s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m33s
CI/CD Pipeline / Integration Tests (push) Successful in 2m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Validate - Security (push) Successful in 5m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m57s
AI Code Review / AI Code Review (pull_request) Failing after 6m12s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m11s
CI/CD Pipeline / Unit Tests (push) Successful in 8m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-06 15:09:18 +08:00
161 changed files with 10400 additions and 2732 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")
+6
View File
@@ -11,6 +11,7 @@ from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
@@ -99,6 +100,11 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_variant_plans_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
+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)
+84 -4
View File
@@ -75,6 +75,86 @@ class GenerateCoverResponse(BaseModel):
# ── Route ────────────────────────────────────────────────────────────────
def _select_best_frame_from_snapshots(
snapshots: list[dict], plan_id: str
) -> str:
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
降级策略:cv2 不可用或评分失败时,返回第一帧。
Args:
snapshots: MediaKit 返回的帧列表 [{"image_url": str, ...}, ...]
plan_id: 计划 ID(日志用)
Returns:
最佳帧的 image_url,或空字符串
"""
if not snapshots:
return ""
if len(snapshots) == 1:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
try:
import tempfile
import httpx
from packages.shared.cover_frame_scorer import score_frames
scored_candidates = []
for snap in snapshots:
url = snap.get("image_url") or snap.get("url") or ""
if not url:
continue
# 下载帧到临时文件进行评分
try:
resp = httpx.get(url, timeout=15, follow_redirects=True)
resp.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
scored_candidates.append({"image_path": tmp_path, "url": url})
except Exception:
# 下载失败的帧跳过,给默认低分
scored_candidates.append({"image_path": None, "url": url, "score": 0.0})
if not scored_candidates:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
scored = score_frames(scored_candidates)
best = scored[0] if scored else None
best_url = best.get("url", "") if best else ""
best_score = best.get("score", 0.0) if best else 0.0
logger.info(
"[封面生成] 帧质量评分完成: plan_id=%s candidates=%d best_score=%.1f",
plan_id,
len(scored_candidates),
best_score,
)
# 清理临时文件
for c in scored_candidates:
path = c.get("image_path")
if path:
try:
from pathlib import Path
Path(path).unlink(missing_ok=True)
except Exception:
pass
return best_url
except Exception:
logger.warning(
"[封面生成] 帧质量评分失败,使用第一帧: plan_id=%s",
plan_id,
exc_info=True,
)
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
def _persist_cover_frame(
frame_url: str,
plan_id: str,
@@ -652,13 +732,13 @@ def generate_cover(
snapshots = mk_client.extract_frames(
video_url=primary_video_url,
strategy="SpecifiedFrames",
max_frames=1,
max_frames=5, # 抽 5 帧,通过质量评分选最佳
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
raw = _select_best_frame_from_snapshots(snapshots, plan_id)
if raw:
cover_url_from_task = _persist_cover_frame(raw, plan_id)
logger.info(
@@ -715,13 +795,13 @@ def generate_cover(
snapshots = mk_client.extract_frames(
video_url=src_url,
strategy="SpecifiedFrames",
max_frames=1,
max_frames=5, # 抽 5 帧,通过质量评分选最佳
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
raw = _select_best_frame_from_snapshots(snapshots, plan_id)
if raw:
cover_url_from_task = _persist_cover_frame(
raw,
+89 -14
View File
@@ -390,7 +390,6 @@ def create_preview_generation_task(
template_id=request.template_id,
asset_ids=list(request.asset_ids),
title_ids=list(request.title_ids),
voice_ids=list(request.voice_ids),
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode="",
@@ -420,42 +419,118 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)──
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。
# ── 独立变体 plan#1743)──
# count=1:克隆源 plan(预览不污染源 plan,仅起点重算),行为与旧版一致;
# count>1:变体 0 保留源 plan,变体 1..N-1 用 reselect_plan_for_variant 完整
# 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选),
# 所见即所得——预览变体差异即正式成片差异。
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else ""
if source_plan_id:
# #1749:各变体配音解析(严格守卫已在 schema;此处取每变体 voice 查时长)+ 时长分配
def _preview_voice_durations() -> list[float]:
try:
from packages.domain.variant_voice_resolver import resolve_variant_voice_ids
voices = resolve_variant_voice_ids(
count=count,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
except Exception:
logger.warning("[预览生成] 配音解析失败(按无配音处理)", exc_info=True)
return [0.0] * count
try:
from app.api.routes.generation_tasks import _query_voice_durations
return _query_voice_durations(db, voices)
except Exception:
return [0.0] * count
voice_durations = _preview_voice_durations()
if source_plan_id and count == 1:
# 单预览:克隆一份(原逻辑)+ 配音时长分配
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
for variant_index in range(count):
variant_plan = _plan_svc.clone_plan_for_variant(
source_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
if voice_durations and voice_durations[0] > 0:
try:
_plan_svc.apply_voice_duration_to_plan(variant_plan.id, voice_durations[0])
except Exception:
logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", variant_plan.id)
variant_plan_ids.append(variant_plan.id)
except Exception as e:
logger.error("[预览生成] 克隆预览 plan 异常: %s", e, exc_info=True)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from e
elif source_plan_id and count > 1:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
# #1749:变体 0 也 clone(不污染源 plan+ 配音分配;变体 1..N-1 独立选片
_plan0 = _plan_svc.clone_plan_for_variant(
source_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体1",
)
if voice_durations and voice_durations[0] > 0:
try:
_plan_svc.apply_voice_duration_to_plan(_plan0.id, voice_durations[0])
except Exception:
logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", _plan0.id)
variant_plan_ids.append(_plan0.id)
batch_asset_pool = list(dict.fromkeys(request.asset_ids or []))
for variant_index in range(1, count):
last_err: Exception | None = None
variant_plan = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant_plan = _plan_svc.clone_plan_for_variant(
variant_plan = _plan_svc.reselect_plan_for_variant(
source_plan_id,
batch_asset_pool,
created_by_user_id=user_id,
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体",
name_suffix=f"预览变体{variant_index + 1}",
voice_duration=(
voice_durations[variant_index] if variant_index < len(voice_durations) else 0.0
),
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
except ValueError as ve:
logger.warning("[预览生成] 变体独立选片失败(素材不足): %s", ve)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体选片失败")
raise HTTPException(
status_code=400,
detail=f"批量预览第 {variant_index + 1} 个视频无法独立选片:{ve}"
"请增加素材库中的视频素材后重试。",
) from ve
except Exception as reselection_err: # noqa: PERF203
last_err = reselection_err
logger.warning(
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s",
"[预览生成] 变体独立选片失败(尝试%d/2): variant=%d error=%s",
_attempt + 1,
variant_index,
clone_err,
reselection_err,
exc_info=True,
)
if variant_plan is None:
logger.error(
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
"[预览生成] 变体独立选片重试仍失败: variant=%d source=%s",
variant_index,
source_plan_id,
exc_info=last_err,
)
# 标记已创建任务失败
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
@@ -466,7 +541,7 @@ def create_preview_generation_task(
except HTTPException:
raise
except Exception as e:
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
logger.error("[预览生成] 变体 plan 生成异常: %s", e, exc_info=True)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
+225 -44
View File
@@ -57,6 +57,30 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
return values[index] if index < len(values) else fallback
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
"""
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
if not ids:
return []
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) for v in ids]
except Exception:
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in ids]
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -123,6 +147,7 @@ def _select_assets_from_library(
assets: list,
mode: str,
count: int,
rng=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -130,6 +155,8 @@ def _select_assets_from_library(
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效)
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
测试可注入固定种子或零噪声随机源获得确定性结果。
Returns:
选中的素材 ID 列表
@@ -142,8 +169,9 @@ def _select_assets_from_library(
if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
limit = count if count > 0 else None
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
@@ -432,55 +460,199 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
# - 变体 0clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
# - #1749:前端可回传 variant-plans 接口预生成的 plan_idvariant_plan_ids),直接复用;
# 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长);
# - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan
# 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音);
# - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。
# 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。
variant_plan_ids: list[str] = []
if count > 1 and request.source_edit_plan_id:
if count > 1:
from app.services.edit_plan_service import EditPlanService
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
_plan_svc = EditPlanService(db)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
# 解析每变体配音(严格守卫:独立配音长度/缺值 → 400,禁静默 fallback
try:
variant_voices = resolve_variant_voice_ids(
count=count,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
except VariantVoiceError as ve:
raise HTTPException(status_code=400, detail=str(ve)) from ve
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
voice_durations = _query_voice_durations(db, variant_voices)
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
batch_source_plan_id = request.source_edit_plan_id
if not batch_source_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
batch_source_plan_id = _latest.id
except Exception:
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
if not batch_source_plan_id and not request.variant_plan_ids:
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan", count)
raise HTTPException(
status_code=400,
detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。",
)
# 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids
batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or []))
if request.variant_plan_ids:
# ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan)
if len(request.variant_plan_ids) != count:
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
status_code=400,
detail=f"variant_plan_ids 数量({len(request.variant_plan_ids)})与视频数量({count})不一致",
)
# 校验归属权
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
for _pid in request.variant_plan_ids:
_pm = db.query(EditPlanModel).filter(EditPlanModel.id == _pid).first()
if _pm is None:
raise HTTPException(status_code=400, detail=f"剪辑计划不存在: {_pid}")
if _pm.created_by_user_id and _pm.created_by_user_id != user_id:
raise HTTPException(status_code=403, detail=f"无权使用剪辑计划: {_pid}")
variant_plan_ids = list(request.variant_plan_ids)
else:
# ② 服务端选片:变体 0 clone 源 plan(不污染源 plan
try:
_plan0 = _plan_svc.clone_plan_for_variant(
batch_source_plan_id,
created_by_user_id=user_id,
name_suffix="批量1",
)
except Exception as clone_err:
logger.error("[生成任务] 变体0 clone 失败: %s", clone_err, exc_info=True)
raise HTTPException(
status_code=500, detail="创建批量任务失败:无法生成独立剪辑计划,请重试"
) from clone_err
variant_plan_ids.append(_plan0.id)
# 变体 1..N-1 独立选片
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.reselect_plan_for_variant(
batch_source_plan_id,
batch_asset_pool,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
)
break
except ValueError as ve:
# 素材不足等可预期错误:不重试,直接中断并给出明确提示
logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve)
raise HTTPException(
status_code=400,
detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}"
"请增加素材库中的视频素材后重试。",
) from ve
except Exception as reselection_err: # noqa: PERF203
last_err = reselection_err
logger.warning(
"[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
batch_source_plan_id,
reselection_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s",
batch_source_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
for _vi, _pid in enumerate(variant_plan_ids):
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
if _vd > 0:
try:
_plan_svc.apply_voice_duration_to_plan(_pid, _vd)
except Exception:
logger.exception("[生成任务] 变体%d 配音时长分配失败(不阻断): plan=%s", _vi, _pid)
# N=1 正式生成:渲染侧全局慢放兜底已删除(#1749),enqueue 前也必须按配音分配段长
if count == 1 and not request.is_preview:
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
try:
_voices = resolve_variant_voice_ids(
count=1,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
_single_vd: list[float] = _query_voice_durations(db, _voices)
_single_dur = _single_vd[0] if _single_vd else 0.0
_single_plan = request.source_edit_plan_id
if not _single_plan and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
_single_plan = _latest.id
except Exception:
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
if _single_dur > 0 and _single_plan:
from app.services.edit_plan_service import EditPlanService
try:
EditPlanService(db).apply_voice_duration_to_plan(_single_plan, _single_dur)
except Exception:
logger.exception("[生成任务] N=1 配音时长分配失败(不阻断): plan=%s", _single_plan)
except VariantVoiceError as ve:
raise HTTPException(status_code=400, detail=str(ve)) from ve
except Exception:
logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)")
try:
for task_index in range(count):
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
# #1749count>1 时每个变体(含变体0)都关联各自独立 planclone/reselect/variant-plans
if count > 1 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index]
else:
effective_plan_id = request.source_edit_plan_id
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
@@ -500,7 +672,6 @@ def create_generation_task(
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=effective_plan_id,
asset_select_mode=request.asset_select_mode,
@@ -523,7 +694,19 @@ def create_generation_task(
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
# #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成,
# 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。
if not task.source_edit_plan_id and count > 1:
logger.error(
"[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s",
task_index,
task.id,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。",
)
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
@@ -690,7 +873,6 @@ def confirm_generation(
template_id=source_task.template_id,
asset_ids=source_task.asset_ids,
title_ids=source_task.title_ids,
voice_ids=source_task.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=source_task.source_edit_plan_id or "",
asset_select_mode=source_task.asset_select_mode,
@@ -834,7 +1016,6 @@ def retry_generation_task(
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
@@ -0,0 +1,178 @@
"""轻量选片接口 POST /generation/variant-plans#1749)。
与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans →
clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector),
但**不建任务、不入队、不渲染**
- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表;
- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan
不再重复选片(回传后仍按各变体配音幂等重分配段长);
- 配音守卫:voice_library_ids 长度/缺值 → 400variant_voice_resolver),
禁静默 fallback
- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
logger = logging.getLogger(__name__)
router = APIRouter()
class VariantPlanRequest(BaseModel):
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan")
asset_ids: list[str] = Field(default_factory=list, description="批次素材池")
count: int = Field(default=1, ge=1, le=50, description="变体数量")
source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)")
# 配音(可选;传独立配音时严格守卫)
voice_library_id: str = Field(default="", description="统一配音 ID")
voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)")
@model_validator(mode="after")
def _validate(self) -> "VariantPlanRequest":
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
try:
resolve_variant_voice_ids(
count=self.count,
voice_library_id=self.voice_library_id,
voice_library_ids=self.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise ValueError(str(exc)) from exc
return self
class VariantPlanItem(BaseModel):
variant_index: int
plan_id: str
clips: list[dict[str, Any]] = Field(default_factory=list)
class VariantPlanResponse(BaseModel):
items: list[VariantPlanItem]
total: int
@router.post("/variant-plans", response_model=VariantPlanResponse)
def create_variant_plans(
request: VariantPlanRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> VariantPlanResponse:
"""轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。
Returns:
200 + {items: [{variant_index, plan_id, clips}], total}
"""
user_id = authenticated_user.user.id
# 配音严格守卫(schema 已校验,此处复用解析取每变体配音)
try:
voices = resolve_variant_voice_ids(
count=request.count,
voice_library_id=request.voice_library_id,
voice_library_ids=request.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
source_plan_id = request.source_edit_plan_id.strip()
if not source_plan_id and request.template_id.strip():
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
source_plan_id = _latest.id
except Exception:
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
if not source_plan_id:
raise HTTPException(
status_code=400,
detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。",
)
# 配音时长(硬化:异常 → 0.0 不阻断选片)
try:
from app.api.routes.generation_tasks import _query_voice_durations
voice_durations = _query_voice_durations(db, voices)
except Exception:
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
voice_durations = [0.0] * request.count
from app.services.edit_plan_service import EditPlanService
svc = EditPlanService(db)
try:
plan_ids = svc.ensure_variant_plans(
source_plan_id,
request.count,
list(dict.fromkeys(request.asset_ids or [])),
created_by_user_id=user_id,
voice_durations=voice_durations,
)
except ValueError as ve:
# 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径)
logger.warning("[variant-plans] 选片失败: %s", ve)
raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve
except HTTPException:
raise
except Exception as e:
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
# 组装 clips 响应
items: list[VariantPlanItem] = []
for idx, pid in enumerate(plan_ids):
clips = svc.list_clips(pid)
clip_dicts = [
{
"id": c.id,
"order": c.order,
"asset_id": c.asset_id,
"start_time": float(c.start_time or 0.0),
"duration": float(c.duration or 0.0),
"clip_type": c.clip_type,
"transition_effect": c.transition_effect,
"transition_duration": float(c.transition_duration or 0.0),
"playback_speed": float(c.playback_speed or 1.0),
"text_content": c.text_content or "",
"status": c.status or "ready",
}
for c in clips
]
items.append(VariantPlanItem(variant_index=idx, plan_id=pid, clips=clip_dicts))
logger.info(
"[variant-plans] 轻量选片完成: user=%s source=%s count=%d plans=%d",
user_id,
source_plan_id,
request.count,
len(plan_ids),
)
return VariantPlanResponse(items=items, total=len(items))
+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_idis_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(
+44 -10
View File
@@ -36,9 +36,13 @@ class CreateGenerationTaskRequest(BaseModel):
template_id: str = ""
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── variant-plans 轻量选片回传(#1749):正式生成直接复用,不再重选 ──
variant_plan_ids: list[str] = Field(
default_factory=list,
description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片",
)
# ── 标题配置(结构化)──
title_config: dict | None = Field(
default=None,
@@ -96,11 +100,31 @@ class CreateGenerationTaskRequest(BaseModel):
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = count(独立)。"""
for name in ("voice_library_ids", "cover_urls", "titles"):
"""变体数组字段长度校验 + #1749 配音严格守卫。
- cover_urls/titles:空(回退单值)、长度 1(共用)或长度 = count(独立);
- voice_library_ids:独立配音长度必须恰好 = count 且逐项非空,禁止静默 fallback
(长度 1 的"共用"场景请用 voice_library_id 单值字段);
- variant_plan_ids:非空时长度必须 = count。
"""
for name in ("cover_urls", "titles"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.count:
raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}")
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
try:
resolve_variant_voice_ids(
count=self.count,
voice_library_id=self.voice_library_id,
voice_library_ids=self.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise ValueError(str(exc)) from exc
if self.variant_plan_ids and len(self.variant_plan_ids) != self.count:
raise ValueError(f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致")
return self
@model_validator(mode="after")
@@ -110,9 +134,9 @@ class CreateGenerationTaskRequest(BaseModel):
if not has_project and not has_template:
raise ValueError("project_id 或 template_id 至少需要提供一个")
has_library = bool(self.asset_library_id.strip())
has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids)
has_assets = bool(self.asset_ids or self.title_ids)
if not has_library and not has_assets:
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
raise ValueError("asset_library_id 或 asset_ids/title_ids 至少需要提供一个")
return self
@@ -189,7 +213,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
template_id: str
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
voice_library_id: str = Field(
default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材"
)
@@ -231,13 +254,24 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest":
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = preview_count(独立)"""
for name in ("titles", "voice_library_ids", "cover_urls"):
"""变体数组字段长度校验 + #1749 配音严格守卫"""
for name in ("titles", "cover_urls"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.preview_count:
raise ValueError(
f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}"
)
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
try:
resolve_variant_voice_ids(
count=self.preview_count,
voice_library_id=self.voice_library_id,
voice_library_ids=self.voice_library_ids or None,
)
except VariantVoiceError as exc:
raise ValueError(str(exc)) from exc
return self
@model_validator(mode="after")
@@ -248,8 +282,8 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
@model_validator(mode="after")
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
if not self.asset_ids and not self.title_ids and not self.voice_ids:
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
if not self.asset_ids and not self.title_ids:
raise ValueError("asset_ids/title_ids 至少需要提供一个")
return self
+395
View File
@@ -409,8 +409,13 @@ class EditPlanService:
clip_type=clip_item.get("clip_type", "main"),
order=order,
asset_id=clip_item.get("asset_id", ""),
text_content=clip_item.get("text_content", ""),
start_time=clip_item.get("start_time", 0.0),
duration=clip_item.get("duration", 0.0),
transition_effect=clip_item.get("transition_effect", "cut"),
transition_duration=clip_item.get("transition_duration", 0.0),
playback_speed=clip_item.get("playback_speed", 1.0),
config=clip_item.get("config") or None,
)
model = EditPlanClipModel(
id=clip.id,
@@ -459,6 +464,159 @@ class EditPlanService:
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
raise
def reselect_plan_for_variant(
self,
source_plan_id: str,
candidate_asset_ids: list[str],
*,
created_by_user_id: str = "",
name_suffix: str = "变体",
voice_duration: float = 0.0,
rng=None,
) -> EditPlan:
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,本方法:
- 源 plan 片段骨架(clip_type/order/duration/文案/转场)保留;
- 素材池 shuffle 随机分配 + main 片段顺序洗牌;
- 起点走场景镜头洗牌/随机起点/历史区间避让(与单视频同一入口);
- 批次内同素材区间重叠 >20% 自动重选起点;
- 新片段区间 record_used_segments 写回素材 metadata(跨变体/跨任务避让)。
Args:
source_plan_id: 源 plan(任务 0 / 预览源)。
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次素材)。
created_by_user_id: 新 plan 归属用户。
name_suffix: plan 名后缀。
rng: 可选随机数(测试注入种子)。
Raises:
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
from packages.domain.variant_plan_selector import reselect_clips_for_variant
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
raise ValueError(f"源 plan 无片段,无法生成变体: {source_plan_id}")
source_clips_data: list[dict[str, Any]] = [
{
"order": c.order if c.order is not None else i,
"asset_id": c.asset_id,
"start_time": float(c.start_time or 0.0),
"duration": float(c.duration or 0.0),
"clip_type": c.clip_type,
"playback_speed": float(c.playback_speed or 1.0),
"transition_effect": c.transition_effect,
"transition_duration": float(c.transition_duration or 0.0),
"text_content": c.text_content or "",
"config": c.config or {},
}
for i, c in enumerate(clips)
]
db = self._clip_repo.session
# #1749:配音时长 → 每段目标段长(片段数=模板片段数定死;素材不足由渲染末帧冻结铺满)
target_durations: list[float] | None = None
try:
voice = float(voice_duration or 0.0)
except (TypeError, ValueError):
voice = 0.0
if voice > 0 and source_clips_data:
from packages.domain.voice_duration_planner import plan_clip_durations
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
target_durations = plan_clip_durations(
len(source_clips_data),
voice,
transition_effects=_effects,
transition_durations=_tdurs,
)
if target_durations:
for _c, _d in zip(source_clips_data, target_durations, strict=False):
_c["duration"] = _d
# 素材池 = 源 plan 素材 ∪ 调用方传入素材(去重保序)
pool_ids: list[str] = []
seen = set()
for aid in [c.asset_id for c in clips if c.asset_id] + list(candidate_asset_ids or []):
if aid and aid not in seen:
seen.add(aid)
pool_ids.append(aid)
# 时长 + 场景点
durations: dict[str, float] = {}
scene_points: dict[str, list[float]] = {}
if pool_ids:
for m in db.query(AssetModel).filter(AssetModel.id.in_(pool_ids)).all():
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
pts = extract_scene_points_from_metadata(getattr(m, "metadata", None))
if pts:
scene_points[m.id] = pts
historical = get_used_segments(db, pool_ids)
# 创建新 plan(复制模板归属与 config
new_plan = self.create_plan(
template_id=source.template_id,
name=f"{source.name or '剪辑计划'} · {name_suffix}",
config=dict(source.config or {}),
total_duration=source.total_duration,
project_id=source.project_id or "",
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
batch_segments: dict[str, list[tuple[float, float]]] = {}
for c in clips:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
clips_data = reselect_clips_for_variant(
source_clips_data,
pool_ids,
asset_durations=durations,
asset_scene_points=scene_points,
historical_used_segments=historical,
batch_segments=batch_segments,
target_durations=target_durations,
rng=rng,
)
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit
for item in clips_data:
aid = item.get("asset_id", "")
if aid:
st = float(item.get("start_time", 0.0))
record_used_segments(db, aid, st, st + float(item.get("duration", 0.0)), new_plan.id)
self.replace_all_clips_transactional(new_plan.id, clips_data)
logger.info(
"变体独立选片完成: source=%s new=%s clips=%d assets=%d",
source_plan_id,
new_plan.id,
len(clips_data),
len(pool_ids),
)
return new_plan
def clone_plan_for_variant(
self,
source_plan_id: str,
@@ -569,6 +727,243 @@ class EditPlanService:
)
return new_plan
# ── #1749 配音时长分配 / 素材时长查询 / 批量变体 plan 确保 ──────────────
def get_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
"""批量查询素材时长(秒),O(N) 单查;缺失/异常返回 0.0。"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
ids = [a for a in dict.fromkeys(asset_ids or []) if a]
if not ids:
return {}
db = self._clip_repo.session
out: dict[str, float] = {}
for m in db.query(AssetModel).filter(AssetModel.id.in_(ids)).all():
try:
out[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
except (TypeError, ValueError):
out[m.id] = 0.0
return out
def apply_voice_duration_to_plan(self, plan_id: str, voice_duration: float) -> Optional[EditPlan]:
"""把配音时长分配到 plan 的每段(#1749)。
- 片段数保持不变(= 模板片段数,定死);
- 每段 duration 按 voice_duration_planner 分配(含转场重叠扣减);
- 素材短于段长 → start_time 钳制为 0(末帧冻结由渲染侧 tpad/apad 铺满);
- plan.total_duration 回写为成片净时长(≈ 配音时长);
- 幂等:配音时长相同则分配结果不变,可重复调用。
无配音(<=0)或无片段时直接返回 None,不报错。
"""
try:
voice = float(voice_duration or 0.0)
except (TypeError, ValueError):
return None
if voice <= 0:
return None
plan = self.get_plan(plan_id)
if plan is None:
return None
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
return None
clips.sort(key=lambda c: (c.order if c.order is not None else 0))
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
# 素材时长(短素材起点钳 0
asset_ids = [c.asset_id for c in clips if c.asset_id]
durations = self.get_asset_durations(asset_ids)
clips_data: list[dict] = []
for i, c in enumerate(clips):
dur = float(target[i])
total = durations.get(c.asset_id, 0.0)
start = float(c.start_time or 0.0)
if c.asset_id and total > 0:
# 素材短于段长:起点钳 0,段长超出部分渲染侧末帧冻结
max_start = max(0.0, total - min(dur, total))
start = min(start, max_start)
clips_data.append(
{
"order": c.order if c.order is not None else i,
"asset_id": c.asset_id or "",
"start_time": round(start, 3),
"duration": dur,
"clip_type": c.clip_type,
"playback_speed": float(c.playback_speed or 1.0),
"transition_effect": c.transition_effect,
"transition_duration": float(c.transition_duration or 0.0),
"text_content": c.text_content or "",
"config": c.config or {},
}
)
self.replace_all_clips_transactional(plan_id, clips_data)
net = total_output_duration(
target,
transition_effects=[c.transition_effect for c in clips],
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
)
try:
plan.total_duration = net
db = self._clip_repo.session
db.commit()
except Exception:
db.rollback()
logger.exception("回写 plan.total_duration 失败(不阻断): plan_id=%s", plan_id)
logger.info(
"配音时长分配完成: plan=%s clips=%d voice=%.2fs 成片净时长=%.2fs",
plan_id,
len(clips),
voice,
net,
)
return plan
def ensure_variant_plans(
self,
source_plan_id: str,
count: int,
candidate_asset_ids: list[str],
*,
created_by_user_id: str = "",
voice_durations: Optional[list[float]] = None,
rng=None,
) -> list[str]:
"""确保批量 N 个变体各自拥有独立 plan(#1749 批量正式生成/预览共用)。
- 变体 0clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长;
- 变体 1..N-1reselect_plan_for_variant 完整重跑选片(素材级去重);
- voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值);
缺省/为 0 时不分配(段长保持骨架/模板值)。
Returns:
plan_id 列表,长度 == countindex 即 variant_index。
"""
import random as _random
rng = rng or _random.Random()
plan_ids: list[str] = []
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
plan0 = self.clone_plan_for_variant(
source_plan_id,
created_by_user_id=created_by_user_id,
name_suffix="变体1",
)
v0_voice = 0.0
if voice_durations and len(voice_durations) > 0:
try:
v0_voice = float(voice_durations[0] or 0.0)
except (TypeError, ValueError):
v0_voice = 0.0
if v0_voice > 0:
try:
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
except Exception:
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
plan_ids.append(plan0.id)
# 变体 1..N-1:独立选片
for i in range(1, count):
voice = 0.0
if voice_durations and i < len(voice_durations):
try:
voice = float(voice_durations[i] or 0.0)
except (TypeError, ValueError):
voice = 0.0
variant = self.reselect_plan_for_variant(
source_plan_id,
candidate_asset_ids,
created_by_user_id=created_by_user_id,
name_suffix=f"变体{i + 1}",
voice_duration=voice,
rng=rng,
)
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
for idx, pid in enumerate(plan_ids):
try:
perturbation = generate_visual_perturbation(rng)
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
if idx == 0:
perturbation["hflip"] = False
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)
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
for pid in plan_ids:
try:
self.mark_clips_ready(pid)
except Exception:
logger.exception("标记 clips ready 失败(不阻断): plan=%s", pid)
return plan_ids
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
+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
+8 -4
View File
@@ -44,8 +44,10 @@ export interface BgmConfig {
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
/** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取,新模板可不传 */
duration_min?: number
/** @deprecated 同上 */
duration_max?: number
material_type: string | null
}
@@ -59,7 +61,8 @@ export interface EditingTemplate {
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
/** @deprecated 模板无时长概念(#1750 基线):成片时长由配音时长决定;字段保留兼容旧数据 */
estimated_duration?: number
segments: TemplateSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
@@ -89,7 +92,8 @@ export interface SaveTemplatePayload {
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
/** @deprecated 模板无时长概念(#1750 基线):保留兼容旧数据 */
estimated_duration?: number
segments: Omit<TemplateSegment, "id">[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
@@ -0,0 +1,61 @@
/**
* 批量变体剪辑计划 API(#1744)
*
* 批量预览时向后端申请 N 个变体的「独立剪辑计划片段」:
* - 变体 0 保留源 plan(用户在编辑器/智能选片产出的片段,含标题样式编辑结果);
* - 变体 1..N-1 由后端 reselect_plan_for_variant 完整重跑单视频选片流程
* (素材洗牌 + main 片段顺序洗牌 + 镜头/起点随机 + 跨变体 20% 区间避让 +
* 素材使用区间写回 metadata),与正式批量生成 POST /generation/tasks?count=N
* 使用同一套选片逻辑;
* - 正式生成时把 variant_plan_ids 原样回传,后端直接关联这些 plan 渲染,
* 不再重新选片 —— 预览所见即成片。
*
* 该接口只做选片/建 plan(秒级),不触发视频渲染,无渲染成本。
* 后端端点未上线(404)或选片失败(素材不足等)时前端降级为本地 variantSeed
* 模拟预览,不阻塞用户流程。
*/
import apiClient from "../client"
import type { EditPlanClip } from "../template-editor"
/** 批量变体计划请求体 */
export interface BatchVariantPlansRequest {
template_id: string
/** 本批次素材池(手动选择或智能匹配结果) */
asset_ids: string[]
/** 变体数量(≥1);=1 时只返回源 plan 片段 */
count: number
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
source_edit_plan_id?: string
}
/** 单个变体的计划片段 */
export interface VariantPlan {
/** 变体序号,从 0 开始 */
variant_index: number
/** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */
plan_id: string
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
clips: EditPlanClip[]
}
/** 批量变体计划响应 */
export interface BatchVariantPlansResponse {
items: VariantPlan[]
total: number
}
/**
* 创建批量变体剪辑计划并返回各变体片段。
*
* 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级,
* 不要抛 unhandled rejection。
*/
export async function createBatchVariantPlans(
params: BatchVariantPlansRequest,
): Promise<BatchVariantPlansResponse> {
const response = await apiClient.post<BatchVariantPlansResponse>(
"/generation/variant-plans",
params,
)
return response.data
}
+6
View File
@@ -100,6 +100,12 @@ export interface CreateGenerationTaskRequest {
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
cover_urls?: string[]
/**
* 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表
* (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些
* plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。
*/
variant_plan_ids?: string[]
}
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */
+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
}
+4 -4
View File
@@ -42,8 +42,10 @@ export interface TemplateItem {
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
/** @deprecated 模板无时长概念(#1750 基线):字段保留仅为兼容旧数据读取 */
duration_min?: number
/** @deprecated 同上 */
duration_max?: number
material_type: string | null
description?: string
}
@@ -55,8 +57,6 @@ export interface TemplateListParams {
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
@@ -108,7 +108,10 @@ export function usePlanLoading({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
duration:
seg.duration_min != null && seg.duration_max != null
? (seg.duration_min + seg.duration_max) / 2
: 5,
startOffset: 0,
script_text: "",
order: seg.segment_order,
@@ -43,7 +43,10 @@ export function useTemplateDetail({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
duration:
seg.duration_min != null && seg.duration_max != null
? (seg.duration_min + seg.duration_max) / 2
: 5,
startOffset: 0,
script_text: "",
order: seg.segment_order,
+118 -43
View File
@@ -27,6 +27,8 @@ import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
@@ -43,6 +45,7 @@ const GeneratePage: React.FC = () => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -136,14 +139,17 @@ const GeneratePage: React.FC = () => {
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
useEffect(() => {
// #1750:批量模式每变体独立/共用配音由 useVariantVoicePreview 接管(含独立模式每卡片挂各自 URL),
// 本 effect 只服务单视频,批量模式直接跳过(避免共用 TTS 结果错误下发给所有变体)
if (isBatch) return
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
if (voiceAsset?.file_url) {
setPreviewVoiceAudioUrl(voiceAsset.file_url)
return
}
// 批量模式下 TTS 文案跟随变体0标题;单视频跟随主标题
const ttsTitle = isBatch ? variant0Title || "" : titleSettings.title
// 单视频 TTS 文案跟随主标题
const ttsTitle = titleSettings.title
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !ttsTitle) {
setPreviewVoiceAudioUrl(null)
@@ -213,14 +219,56 @@ const GeneratePage: React.FC = () => {
/* ── 加载素材详情(供前端预览播放器使用) ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
previewAssetIds,
previewAssetsEnabled,
)
const {
assets: previewAssets,
ready: previewAssetsReady,
ensureAssets,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */
const previewReady = previewAssetsReady && !!currentTemplate
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
const {
clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds,
loading: variantClipsLoading,
error: variantClipsError,
retry: retryVariantClips,
} = useBatchVariantPlans({
enabled: isBatch && currentStep === 4 && previewAssetsReady,
count: previewCount,
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
})
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
hook 内部自取配音素材库并做 TTS 缓存/串行/防竞态 */
const variantVoiceAudioUrls = useVariantVoicePreview({
enabled: isBatch,
count: previewCount,
perVideo: voiceModePerVideo,
sharedVoiceId: selectedVoice || "",
clonedVoiceId: selectedClonedVoice || "",
variantVoiceIds: voiceLibraryIds || [],
titles: previewTitles || [],
})
/* ── 变体计划 clips 引用的素材可能不在已选列表中(#1750):差集补拉素材详情,
保证播放器拿得到 file_url,而不是静默丢片段/用假数据冒充 */
const clipAssetIds = useMemo(() => {
if (!isBatch || !variantClips?.length) return []
const ids = new Set<string>()
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
return Array.from(ids)
}, [isBatch, variantClips])
useEffect(() => {
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
}, [clipAssetIds, ensureAssets])
/* ── 勾选变体 ── */
const toggleVariantSelect = useCallback(
(index: number) => {
@@ -262,6 +310,8 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
// #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片
variantPlanIds,
previewTaskId,
bgmConfig,
previewCount,
@@ -367,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])
/* ================================================================
渲染
@@ -393,7 +441,6 @@ const GeneratePage: React.FC = () => {
/* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
@@ -425,11 +472,14 @@ const GeneratePage: React.FC = () => {
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
voiceAudioUrls={variantVoiceAudioUrls}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
onRetryClips={retryVariantClips}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
@@ -471,6 +521,7 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
onTemplateInvalid={handleInvalidTemplate}
generating={generating}
generated={generated}
generateError={generateError}
@@ -492,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}
@@ -503,35 +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">
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
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" ? (
@@ -66,12 +80,32 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
</>
)}
{task.status === "completed" && video && (
<video
src={video.download_url || video.file_url}
controls
style={{ width: "100%", borderRadius: 8, background: "#000", maxHeight: 280 }}
poster={video.thumbnail_url}
/>
// 竖屏自适应容器(#1750):成片固定 1080×19209:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "transparent",
borderRadius: 8,
padding: 0,
}}
>
<video
src={video.download_url || video.file_url}
controls
style={{
width: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "cover",
borderRadius: 4,
background: "transparent",
}}
poster={video.thumbnail_url}
/>
</div>
)}
{task.status === "completed" && !video && (
<div className="xx-batch-gen-card-done"> </div>
@@ -1,27 +1,47 @@
/**
* 批量前端 Canvas 实时预览网格(Issue #1677 修正方案
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划
*
* N 个 FrontendPreviewPlayer 网格排列:
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
* - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
* - 勾选框决定提交时生成哪些变体
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
* 加载中 → 9:16 占位防塌陷;
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
* - 勾选框决定提交时生成哪些变体;
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
*/
import React from "react"
import React, { useState } from "react"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
interface CanvasPreviewGridProps {
count: number
assets: AssetItem[]
template: EditingTemplate | null
videoRatio: string
titles: string[]
titleSettings: TitleSettings
/** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠) */
voiceAudioUrl?: string
/**
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
*/
voiceAudioUrls?: (string | null)[]
/**
* 各变体的后端真实片段(#1744/#1750):长度=count。
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
*/
variantClips?: EditPlanClip[][]
/** 是否正在向后端申请变体计划 */
clipsLoading?: boolean
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
clipsError?: boolean
/** 用户点击错误态「重试」 */
onRetryClips?: () => void
/** 勾选的变体序号 */
selectedIds: number[]
onToggleSelect: (index: number) => void
@@ -32,15 +52,21 @@ interface CanvasPreviewGridProps {
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
count,
assets,
template,
videoRatio,
titles,
titleSettings,
voiceAudioUrl,
voiceAudioUrls,
variantClips,
clipsLoading = false,
clipsError = false,
onRetryClips,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750 ──
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10clamp
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
@@ -64,29 +90,54 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
<span> {i + 1}</span>
</label>
</div>
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assets.length > 0}
variantSeed={i + 1}
variantTitle={titles[i] || ""}
voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined}
compact
titleSettings={{
title: titles[i] || "",
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
/>
{clipsError ? (
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
<div className="xx-variant-clips-status" role="alert">
<span className="xx-variant-clips-error-text"></span>
{i === 0 && onRetryClips ? (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={onRetryClips}
style={{ marginTop: 12 }}
>
</Button>
) : null}
</div>
) : clipsLoading || !variantClips?.[i]?.length ? (
// ── 加载态:9:16 占位防塌陷 ──
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
<LoadingOutlined />
<span style={{ marginTop: 8 }}></span>
</div>
) : (
<FrontendPreviewPlayer
assets={assets}
videoRatio={videoRatio}
ready={assets.length > 0}
playToken={i}
serverClips={variantClips[i]}
variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken}
compact
titleSettings={{
title: titles[i] || "",
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
/>
)}
</div>
)
})}
@@ -13,18 +13,19 @@ import {
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
template: EditingTemplate | null
videoRatio: string
ready: boolean
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
serverClips?: EditPlanClip[]
voiceAudioUrl?: string
titleSettings?: {
@@ -42,15 +43,21 @@ interface FrontendPreviewPlayerProps {
}
onTitlePositionChange?: (posX: number, posY: number) => void
/**
* 变体种子(批量生成 #1677):同一批素材在不同变体中采用不同的素材顺序与
* 片段起始点,让 N 个 Canvas 预览画面有差异(纯前端随机剪辑模拟,不调后端)
* 0 / 不传 = 单视频,排布与旧版完全一致(零回归)。
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
* 持有播放权的实例播放,其余自动暂停
*/
variantSeed?: number
playToken?: number
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
variantTitle?: string
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
compact?: boolean
/**
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
*/
activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void
}
function formatTime(seconds: number): string {
@@ -60,114 +67,107 @@ function formatTime(seconds: number): string {
}
/**
* 将素材映射为播放片段(复用原逻辑
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback
*
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
*/
/** 简单可复现随机数(mulberry32),同一种子产出稳定排布,避免每次渲染抖动 */
function seededRandom(seed: number): () => number {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function buildPlaybackSegments(
assets: AssetItem[],
template: EditingTemplate | null,
serverClips?: EditPlanClip[],
variantSeed = 0,
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
speedFactor = 1,
): PlaybackSegment[] {
if (!assets.length) return []
if (!assets.length || !serverClips || serverClips.length === 0) return []
// Build asset lookup map
const assetMap = new Map(assets.map((a) => [a.id, a]))
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
if (serverClips && serverClips.length > 0) {
const segments: PlaybackSegment[] = []
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 30
const startTime = clip.start_time || 0
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
const videoUrl = asset.file_url || asset.storage_key
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
}
if (segments.length > 0) {
return segments.sort((a, b) => a.order - b.order)
}
}
// Fallback: 本地构建片段
// variantSeed=0(单视频):与旧行为完全一致(素材原序、起始点 0),零回归
// variantSeed>0(批量变体):素材顺序按种子轮换 + 片段起始点在素材内偏移,
// 模拟后端"AI 随机剪辑出不同版本",让 N 个预览画面有可见差异
const templateSegments = template?.segments || []
const segments: PlaybackSegment[] = []
const orderedAssets = variantSeed > 0 ? [...assets] : assets
if (variantSeed > 0 && orderedAssets.length > 1) {
const rand = seededRandom(variantSeed * 7919 + 13)
// 素材轮换:把数组旋转 (seed % n) 位,再对后半段做一次稳定交换
const n = orderedAssets.length
const rotate = variantSeed % n
orderedAssets.push(...orderedAssets.splice(0, rotate))
const swapA = Math.floor(rand() * n)
const swapB = Math.floor(rand() * n)
if (swapA !== swapB) {
;[orderedAssets[swapA], orderedAssets[swapB]] = [orderedAssets[swapB], orderedAssets[swapA]]
}
}
orderedAssets.forEach((asset, i) => {
const assetDuration = asset.duration || asset.metadata?.duration || 30
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
: Math.min(assetDuration, 10)
let startTime = 0
if (variantSeed > 0 && assetDuration - segDuration > 1) {
const rand = seededRandom(variantSeed * 104729 + i * 31 + 7)
// 起始点在素材可用区间内随机偏移(至少留 0.5s 余量)
const maxStart = Math.max(0, assetDuration - segDuration - 0.5)
startTime = Math.round(rand() * maxStart * 10) / 10
}
const endTime = Math.min(startTime + segDuration, assetDuration)
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 0
const startTime = clip.start_time || 0
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
const rawClipDuration = clip.duration || 0
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
const endTime =
assetDuration > 0
? Math.min(startTime + clipDuration, assetDuration)
: startTime + clipDuration
const videoUrl = asset.file_url || asset.storage_key
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
})
return segments
segments.push({
assetId: asset.id,
videoUrl,
startTime,
endTime,
order: clip.order,
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
})
}
return segments.sort((a, b) => a.order - b.order)
}
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
template,
videoRatio,
ready,
serverClips,
voiceAudioUrl,
titleSettings,
onTitlePositionChange,
variantSeed = 0,
playToken,
variantTitle,
compact = false,
activePlayToken = null,
onPlayTokenChange,
}) => {
// #1754:测量配音时长,计算缩放因子
const [voiceDuration, setVoiceDuration] = useState(0)
useEffect(() => {
if (!voiceAudioUrl) {
setVoiceDuration(0)
return
}
const audio = new Audio()
audio.preload = "metadata"
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration)) {
setVoiceDuration(audio.duration)
}
}
audio.addEventListener("loadedmetadata", onLoaded)
audio.src = voiceAudioUrl
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
}
}, [voiceAudioUrl])
// #1756clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
const rawClipsDuration = useMemo(() => {
if (!serverClips?.length) return 0
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
}, [serverClips])
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
const speedFactor = useMemo(() => {
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
const ratio = rawClipsDuration / voiceDuration
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
}, [voiceDuration, rawClipsDuration])
const segments = useMemo(
() => buildPlaybackSegments(assets, template, serverClips, variantSeed),
[assets, template, serverClips, variantSeed],
() => buildPlaybackSegments(assets, serverClips, speedFactor),
[assets, serverClips, speedFactor],
)
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
const effectiveTitle = variantTitle ?? titleSettings?.title
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
const TITLE_MARGIN_TOP = 120
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_TOP = 180
const TITLE_MARGIN_BOTTOM = 100
const TITLE_MARGIN_SIDE = 40
const playRes = (() => {
switch (videoRatio) {
@@ -339,6 +339,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
pause: videoPause,
videoRefs,
} = useSegmentScheduler(segments)
@@ -353,6 +354,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl
useEffect(() => {
if (!voiceAudioUrl) {
@@ -370,7 +375,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
}, [voiceAudioUrl])
audioRef.current.muted = muted
}, [voiceAudioUrl, muted])
useEffect(() => {
const audio = audioRef.current
@@ -409,17 +415,42 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
)
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
useEffect(() => {
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) canvasControls.pause()
} else if (isPlaying) {
videoPause()
}
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, playToken, effectiveUseWebCodecs])
const handleTogglePlay = useCallback(() => {
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
onPlayTokenChange?.(null)
} else {
if (playToken != null) onPlayTokenChange?.(playToken)
canvasControls.play()
}
} else {
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
videoTogglePlayPause()
}
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
}, [
effectiveUseWebCodecs,
canvasState.isPlaying,
canvasControls,
videoTogglePlayPause,
isPlaying,
playToken,
onPlayTokenChange,
])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
@@ -573,7 +604,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
maxWidth: compact ? "100%" : 280,
margin: compact ? 0 : "0 auto",
aspectRatio: "9 / 16",
background: "#0a0a0a",
background: compact ? "transparent" : "#0a0a0a",
borderRadius: compact ? 10 : 24,
overflow: "hidden",
boxShadow: compact
@@ -597,7 +628,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
width: "100%",
height: "100%",
objectFit: "contain",
objectFit: "cover",
}}
/>
</div>
@@ -608,7 +639,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
segments.map((seg, i) => (
<video
key={seg.assetId}
muted
muted={hasVoice || muted}
ref={(el) => {
videoRefs.current[i] = el
}}
@@ -619,7 +650,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
@@ -662,8 +693,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
? { top: "50%", transform: "translate(-50%, -50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: onTitlePositionChange && variantSeed === 0 ? "auto" : "none",
cursor: onTitlePositionChange && variantSeed === 0 ? "grab" : "default",
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
@@ -743,6 +774,45 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</button>
)}
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
{segments.length > 0 && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={() => setMuted((m) => !m)}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
@@ -753,9 +823,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: 10,
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: "2px 8px",
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
@@ -774,8 +844,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
right: 0,
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 16px 16px",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
@@ -788,10 +858,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: 16,
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: 32,
height: 32,
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
@@ -811,9 +881,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: 11,
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: 72,
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
@@ -1,96 +0,0 @@
import React from "react"
import { Typography } from "antd"
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
const { Text } = Typography
interface GenerateResultPanelProps {
generated: boolean
generating: boolean
progress: number
generateError: string | null
generatedVideos: GeneratedVideo[]
onVideoPreview: (video: GeneratedVideo) => void
onDownload: () => void
onShare: () => void
onGoToLibrary: () => void
}
const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
generated,
generating,
progress,
generateError,
generatedVideos,
onVideoPreview,
onDownload,
onShare,
onGoToLibrary,
}) => {
return (
<div className="xx-generate-result">
<div className="xx-result-header">
<h3></h3>
{generated && generatedVideos.length > 0 && (
<span className="xx-result-count">{generatedVideos.length} </span>
)}
</div>
{generating && <ProgressIndicator progress={progress} />}
{generateError && !generating && (
<div className="xx-result-empty">
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
<Text strong style={{ display: "block", marginBottom: 4 }}>
</Text>
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{typeof generateError === "string" ? generateError : "请重试"}
</Text>
</div>
)}
{!generated && !generating && !generateError && (
<div className="xx-result-empty">
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
</Text>
<Text style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
</Text>
</div>
)}
{generated && generatedVideos.length > 0 && (
<div className="xx-video-grid">
{generatedVideos.map((video, idx) => (
<ResultVideoCard
key={video.id || idx}
video={video}
index={idx}
onPreview={onVideoPreview}
onDownload={onDownload}
onShare={onShare}
/>
))}
</div>
)}
{generated && (
<div className="xx-result-footer">
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onGoToLibrary}>
</button>
</div>
)}
</div>
)
}
export default GenerateResultPanel
@@ -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:
@@ -1,380 +0,0 @@
/**
* 右侧预览视频面板 — 服务器渲染预览架构
*
* Step4+: 显示预览面板
* Step5: 播放服务器渲染的真实视频(POST /generation/preview
*
* 架构:
* - 进入 Step4/5 时自动创建服务器预览渲染任务
* - 轮询完成后用 <video> 标签播放返回的 video_url
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
* - 素材/配音/BGM 变更自动重新渲染
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
*
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
*/
import React, { useMemo, useRef, useState, useEffect } from "react"
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import { getFontFamily } from "../constants"
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
interface PreviewVideoPanelProps {
/** 服务器预览状态 */
previewStatus: ServerPreviewStatus
/** 服务器渲染视频 URL */
videoUrl: string | null
/** 渲染进度 0-100 */
progress: number
/** 错误信息 */
error: string | null
/** 重新预览回调 */
onRetry: () => void
/** 视频比例 */
videoRatio: string
/** 标题设置 — CSS 实时预览层 */
titleSettings?: TitleSettings
/** 素材数量 */
assetCount?: number
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
const TITLE_MARGIN_TOP = 120
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_SIDE = 40
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY */
function getResolution(ratio: string): { width: number; height: number } {
switch (ratio) {
case "16:9":
return { width: 1920, height: 1080 }
case "1:1":
return { width: 1080, height: 1080 }
case "9:16":
default:
return { width: 1080, height: 1920 }
}
}
function getPositionStyle(
position: string,
playResX: number,
playResY: number,
): React.CSSProperties {
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
switch (position) {
case "bottom":
return {
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
case "center":
return {
top: "50%",
transform: "translateY(-50%)",
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
case "top":
default:
return {
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
}
}
function buildTitleStyle(
settings: TitleSettings,
containerHeight: number,
playResY: number,
): React.CSSProperties {
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
const fontSizePx =
containerHeight > 0
? (settings.size / playResY) * containerHeight
: (settings.size / playResY) * 400
const base: React.CSSProperties = {
fontFamily: getFontFamily(settings.font),
fontSize: `${fontSizePx}px`,
color: settings.color || "#ffffff",
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
lineHeight: 1.05,
wordBreak: "break-word",
pointerEvents: "none",
userSelect: "none",
}
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
return base
}
/** CSS 标题实时预览覆盖层 */
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
titleSettings,
videoRatio,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400)
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height
if (h > 0) setContainerHeight(h)
}
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
const { width: playResX, height: playResY } = getResolution(videoRatio)
const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position, playResX, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
[titleSettings.position, playResX, playResY],
)
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
containerHeight,
titleSettings.font,
titleSettings.size,
titleSettings.color,
titleSettings.bold,
titleSettings.italic,
titleSettings.stroke,
titleSettings.shadow,
playResY,
],
)
const displayTitle = titleSettings.title?.trim() || "标题预览"
return (
<div
ref={containerRef}
style={{
position: "absolute",
inset: 0,
zIndex: 20,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
{displayTitle.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</div>
</div>
)
}
/* ── 主组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
previewStatus,
videoUrl,
progress,
error,
onRetry,
videoRatio,
titleSettings,
assetCount,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
const isLoading = previewStatus === "loading"
const isReady = previewStatus === "ready" || previewStatus === "stale"
const isFailed = previewStatus === "failed"
const isIdle = previewStatus === "idle"
const isStale = previewStatus === "stale"
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3></h3>
{isReady && !isStale && <span className="xx-preview-badge"></span>}
{isStale && (
<span
style={{
fontSize: 12,
color: "#faad14",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<ExclamationCircleOutlined />
</span>
)}
{isLoading && <span className="xx-preview-badge"></span>}
</div>
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
{/* 加载中 */}
{isLoading && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 5,
background: "rgba(0,0,0,0.6)",
}}
>
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
{progress > 0 ? `...${progress}%` : "..."}
</p>
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
30-60
</p>
</div>
)}
{/* 空闲状态(尚未触发预览) */}
{isIdle && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.3)",
zIndex: 5,
}}
>
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>...</p>
</div>
)}
{/* 服务器渲染的真实视频 */}
{isReady && videoUrl && (
<video
key={videoUrl}
src={videoUrl}
controls
autoPlay
loop
playsInline
style={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
inset: 0,
zIndex: 1,
}}
/>
)}
{/* 标题样式实时预览层(仅在有视频时叠加) */}
{isReady && titleSettings && (
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
)}
{/* stale 遮罩:配置变更提示 */}
{isStale && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: "10px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
zIndex: 30,
}}
>
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
</span>
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
</Button>
</div>
)}
{/* 错误状态 */}
{isFailed && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.7)",
zIndex: 10,
padding: 24,
}}
>
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
<p
style={{
marginTop: 12,
color: "rgba(255,255,255,0.9)",
fontSize: 14,
textAlign: "center",
}}
>
{error || "预览渲染失败"}
</p>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={onRetry}
style={{ marginTop: 12 }}
>
</Button>
</div>
)}
</div>
{/* 素材信息 */}
{assetCount !== undefined && assetCount > 0 && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>{assetCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</span>
</div>
</div>
)}
</div>
)
}
export default PreviewVideoPanel
@@ -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" && (
@@ -37,6 +37,12 @@ const isAiVoice = (item: AssetItem): boolean => {
return (!duration || duration <= 0) && (!size || size <= 0)
}
/** 格式化时长(秒 → "14.7秒"0/无效 → "时长未知",不做任何时长过滤) */
const formatDurationSeconds = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "时长未知"
return `${seconds.toFixed(1)}`
}
/** 格式化文件大小 */
const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes <= 0) return "未知"
@@ -261,7 +267,9 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
>
{isAiVoice(item) ? (
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI </span>
) : null}
) : (
<span>{formatDurationSeconds(getDuration(item))}</span>
)}
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
</div>
</div>
@@ -91,11 +91,13 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
shadow: props.titleSettings?.shadow ?? false,
},
covers: batchCoversList,
onCoversChange: (urls) => {
// 按卡片顺序写回对应变体索引
onCoversChange: (updater) => {
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = urls[cardPos] || ""
next[vi] = nextCardView[cardPos] || ""
})
props.onPreviewCoversChange?.(next)
},
@@ -142,7 +144,8 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
>
</Button>
@@ -151,8 +154,8 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<div className="xx-cover-grid">
{cardIndexes.map((variantIndex, cardPos) => {
const url = batchCoversList[cardPos]
const isLoading = batchCovers.loadingIndex === cardPos
const isUploading = batchCovers.uploadingIndex === cardPos
const isLoading = batchCovers.busyIndexes.includes(cardPos)
const isUploading = batchCovers.uploadingIndexes.includes(cardPos)
const title = batchTitles[cardPos]
return (
<div className="xx-cover-card" key={variantIndex}>
@@ -1,40 +0,0 @@
import React from "react"
import { Typography } from "antd"
const { Text } = Typography
interface ProgressIndicatorProps {
progress: number
}
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
return (
<div className="xx-result-progress">
<div className="xx-progress-circle">
<svg viewBox="0 0 80 80">
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="var(--primary-color)"
strokeWidth="6"
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
strokeLinecap="round"
transform="rotate(-90 40 40)"
/>
</svg>
<span className="xx-progress-percent">{Math.round(progress)}%</span>
</div>
<div className="xx-progress-text">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
</Text>
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
AI
</Text>
</div>
</div>
)
}
@@ -1,63 +0,0 @@
import React from "react"
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
import { formatDuration } from "@/api/voice-clone"
interface ResultVideoCardProps {
video: GeneratedVideo
index: number
onPreview: (video: GeneratedVideo) => void
onDownload: () => void
onShare: () => void
}
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
video,
index,
onPreview,
onDownload,
onShare,
}) => {
return (
<div className="xx-video-card" onClick={() => onPreview(video)}>
<div className="xx-video-thumb">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" />
) : (
<div className="xx-video-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-video-play-overlay">
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
</div>
{video.duration && (
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
)}
</div>
<div className="xx-video-info">
<div className="xx-video-title"> {index + 1}</div>
<div className="xx-video-actions">
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onDownload()
}}
>
<DownloadOutlined />
</button>
<button
className="xx-video-action-btn"
onClick={(e) => {
e.stopPropagation()
onShare()
}}
>
<ShareAltOutlined />
</button>
</div>
</div>
</div>
)
}
@@ -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
+58 -38
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;
}
@@ -3282,17 +3258,28 @@
/* ============================================================
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
============================================================ */
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
.xx-canvas-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
gap: 14px;
}
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
@media (max-width: 960px) {
.xx-canvas-grid {
grid-template-columns: minmax(0, 320px);
}
}
.xx-canvas-grid-card {
position: relative;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
background: #000;
background: transparent;
transition: border-color 0.2s ease;
min-width: 0;
}
@@ -3330,6 +3317,37 @@
accent-color: var(--primary-color, #1677ff);
}
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
.xx-variant-clips-status {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
max-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
background: #0a0a0a;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 20px 50px -12px rgba(0, 0, 0, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.xx-variant-clips-status .anticon {
font-size: 22px;
}
.xx-variant-clips-error-text {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
}
/* ============================================================
批量标题:AI 一键生成行(Issue #1677
============================================================ */
@@ -3361,16 +3379,18 @@
第5步确认生成:批量渲染进度网格(Issue #1677
============================================================ */
.xx-batch-gen-grid {
justify-items: center;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
gap: 14px;
}
.xx-batch-gen-card {
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
padding: 14px;
background: var(--bg-surface, #fff);
background: transparent;
display: flex;
flex-direction: column;
gap: 10px;
@@ -3435,10 +3455,10 @@
word-break: break-word;
}
/* ── 响应式:窄屏批量网格回退单列 ── */
/* ── 响应式:窄屏批量网格回退单列.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
@media (max-width: 960px) {
.xx-canvas-grid,
.xx-batch-gen-grid {
grid-template-columns: 1fr;
justify-items: center;
grid-template-columns: minmax(0, 320px);
}
}
@@ -42,6 +42,12 @@ export interface UseGenerateVideoProps {
variantCoverUrls?: string[]
/** 勾选要生成的变体索引(批量模式) */
selectedVariantIndexes?: number[]
/**
* 批量变体剪辑计划 ID(#1744,按变体全量索引长度=previewCount):
* 预览阶段后端独立选片产出,正式生成按勾选顺序回传,实现预览即成片;
* 为空(降级本地模拟/后端未上线)时不传,后端自行独立选片。
*/
variantPlanIds?: string[]
}
/** 生成阶段 */
@@ -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>("")
// 自动选中第一个视频库
@@ -13,21 +13,22 @@ interface UseSmartMatchOptions {
}
/** 默认 limit(拿不到目标时长时的兜底上限) */
const DEFAULT_LIMIT = 10
/** 每个素材切片按 15 秒估算所需素材数 */
const SECONDS_PER_ASSET = 15
const DEFAULT_LIMIT = 30
/**
* 候选池放大倍数(#1744):批量 N 个变体独立选片、跨变体 20% 区间避让需要
* 足够大的素材池才能保证 N 条成片素材排布互不相同(历史已用区间写回素材 metadata)。
* 旧规则「总时长/15 秒」只够 1 条成片的选片量,池太小会导致变体间大量复用同区间。
*/
const ASSET_POOL_MULTIPLIER = 3
/**
* 根据模板 segments 计算所需素材数量上限
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材);
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
* 根据模板 segments 计算智能匹配候选素材数量上限(#1750:模板无时长概念)
* 候选池 = 片段数 × 3:每个片段至少 3 个候选素材供变体间洗牌/避让
* (独立选片 + 区间不重叠);结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
*/
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
export function computeLimitFromSegments(segments?: TemplateSegment[]): number {
if (!segments || segments.length === 0) return DEFAULT_LIMIT
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
if (totalSeconds <= 0) return DEFAULT_LIMIT
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
const limit = segments.length * ASSET_POOL_MULTIPLIER
return Math.max(1, Math.min(limit, 200))
}
@@ -1,6 +1,13 @@
/**
* 批量封面 HookIssue #1677
* 批量封面 HookIssue #1677 / #1750 修复
* N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面
*
* #1750 修复:
* - patchCover 改为函数式 updater 回写(onCoversChange 支持 (prev)=>next),
* 修复串行 generateAll 时闭包捕获旧 covers 数组导致回写互相覆盖的 bug;
* - loading/uploading 状态改数组(busyIndexes/uploadingIndexes),支持多个卡片在途;
* - generateOne 返回 booleangenerateAll 据此统计成功/失败数量分级提示,单个失败不阻塞;
* - generateAll 一次点击即处理所有未生成封面(无需点 3 次)。
*/
import { useCallback, useState } from "react"
import { message } from "antd"
@@ -8,6 +15,9 @@ import { generateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
type CoversChangeFn = (updater: string[] | ((prev: string[]) => string[])) => void
interface UseBatchCoversOptions {
selectedTemplate: string
generatedVideos: GeneratedVideo[]
@@ -24,7 +34,7 @@ interface UseBatchCoversOptions {
shadow: boolean
}
covers: string[]
onCoversChange: (urls: string[]) => void
onCoversChange: CoversChangeFn
}
export function useBatchCovers({
@@ -35,28 +45,46 @@ export function useBatchCovers({
covers,
onCoversChange,
}: UseBatchCoversOptions) {
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
/** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */
const [busyIndexes, setBusyIndexes] = useState<number[]>([])
/** 正在上传自定义封面的变体序号集合 */
const [uploadingIndexes, setUploadingIndexes] = useState<number[]>([])
const addBusy = useCallback((i: number) => {
setBusyIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
}, [])
const removeBusy = useCallback((i: number) => {
setBusyIndexes((prev) => prev.filter((x) => x !== i))
}, [])
const addUploading = useCallback((i: number) => {
setUploadingIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
}, [])
const removeUploading = useCallback((i: number) => {
setUploadingIndexes((prev) => prev.filter((x) => x !== i))
}, [])
/** 函数式回写单个封面位置(基于最新 prev,不受闭包旧值影响) */
const patchCover = useCallback(
(index: number, url: string) => {
const next = [...covers]
next[index] = url
onCoversChange(next)
onCoversChange((prev) => {
const next = [...prev]
next[index] = url
return next
})
},
[covers, onCoversChange],
[onCoversChange],
)
/** 为第 index 个视频自动生成封面 */
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number) => {
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
return
return false
}
setLoadingIndex(index)
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
@@ -82,23 +110,25 @@ export function useBatchCovers({
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面生成成功`)
} else {
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
return true
}
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
return false
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
return false
} finally {
setLoadingIndex(null)
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover],
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
const uploadOne = useCallback(
async (index: number, file: File) => {
setUploadingIndex(index)
addUploading(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
@@ -121,26 +151,43 @@ export function useBatchCovers({
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
message.error("封面上传失败,请重试")
} finally {
setUploadingIndex(null)
removeUploading(index)
}
},
[patchCover],
[patchCover, addUploading, removeUploading],
)
/** 一键全部自动生成(串行,避免队列限流) */
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示 */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
for (let i = 0; i < finalVideos.length; i++) {
if (covers[i]) continue // 已有封面跳过
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
const pending = finalVideos.map((_, i) => i).filter((i) => !covers[i])
if (pending.length === 0) {
message.info("所有视频封面已生成")
return
}
let okCount = 0
let failCount = 0
for (const i of pending) {
// eslint-disable-next-line no-await-in-loop
await generateOne(i)
const ok = await generateOne(i)
if (ok) okCount += 1
else failCount += 1
}
if (failCount === 0) {
message.success(`全部 ${total} 个封面已生成`)
} else if (okCount === 0) {
message.error("封面生成全部失败,请重试")
} else {
message.warning(`已生成 ${okCount} 个封面,${failCount} 个失败,请对失败项重试`)
}
message.success("全部封面已生成")
}, [generatedVideos, covers, generateOne])
return {
loadingIndex,
uploadingIndex,
busyIndexes,
uploadingIndexes,
generateOne,
uploadOne,
generateAll,
@@ -0,0 +1,153 @@
/**
* 批量变体真实片段 Hook#1744 接入 / #1750 修正)
*
* 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans
* 申请 N 个变体的独立剪辑计划片段:
* - 接口只做选片/建 plan(秒级),不渲染视频,无渲染成本;
* - 选片逻辑与正式批量生成(POST /generation/tasks?count=N)完全同源
* reselect_plan_for_variant:素材洗牌 + main 片段洗牌 + 起点随机 +
* 跨变体 20% 区间避让 + 使用区间写回素材 metadata),
* 因此前端按这些 clips 播放的预览与最终成片一致;
* - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。
*
* 错误处理(#1750 基线:严禁用前端假数据冒充预览):
* - 端点 404(后端未上线)/ 400 / 网络错误 / 超时:一律置 error 状态,
* UI 显示「预览加载失败,请重试」,不再静默降级本地模拟;
* - 单个变体 clips 为空同样视为加载失败(计划数据不完整)。
*
* N=1 不调用本 hook(单视频零回归)。
*/
import { useCallback, useEffect, useRef, useState } from "react"
import type { EditPlanClip } from "@/api/template-editor"
import { createBatchVariantPlans, type VariantPlan } from "@/api/generation/variantPlans"
export interface BatchVariantClipsState {
/** 各变体的服务端真实片段(按 variant_index 排序);未就绪为空数组 */
clipsByVariant: EditPlanClip[][]
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
planIdsByVariant: string[]
/** 是否正在向后端申请变体计划 */
loading: boolean
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
ready: boolean
/** 加载失败(端点未上线/网络错误/数据不完整);true 时 UI 显示错误态+重试 */
error: boolean
/** 用户点击「重试」:强制重新申请变体计划 */
retry: () => void
}
interface UseBatchVariantPlansOptions {
/** 是否启用:仅批量(count>1)且素材已选时为 true */
enabled: boolean
count: number
templateId: string
assetIds: string[]
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
sourcePlanId?: string
}
export function useBatchVariantPlans({
enabled,
count,
templateId,
assetIds,
sourcePlanId = "",
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const requestSeqRef = useRef(0)
// 记录上次成功申请的入参指纹,素材/数量未变时不重复请求
const lastKeyRef = useRef("")
// 重试计数(state):用户点重试时 +1,驱动 effect 重新发起请求(即使指纹相同);
// 用 state 而非 ref + 直接调 load,避免 retry 与 effect 双发导致 seq 竞争
const [retryNonce, setRetryNonce] = useState(0)
const load = useCallback(async () => {
const seq = ++requestSeqRef.current
setLoading(true)
setError(false)
try {
const resp = await createBatchVariantPlans({
template_id: templateId,
asset_ids: assetIds,
count,
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
})
if (seq !== requestSeqRef.current) return
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
const planIds: string[] = Array.from({ length: count }, () => "")
for (const item of items) {
const idx = item.variant_index
if (idx < 0 || idx >= count) continue
planIds[idx] = item.plan_id || ""
clips[idx] = (item.clips || [])
.filter((c) => c && c.asset_id && c.status === "ready")
.sort((a, b) => a.order - b.order)
}
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
const incomplete = clips.some((list) => list.length === 0)
if (incomplete) {
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
setClipsByVariant([])
setPlanIdsByVariant([])
setError(true)
} else {
setClipsByVariant(clips)
setPlanIdsByVariant(planIds)
setError(false)
}
} catch (err) {
if (seq !== requestSeqRef.current) return
// 404(后端端点未上线)/ 400(参数错误)/ 网络错误 / 超时:统一错误态
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
setClipsByVariant([])
setPlanIdsByVariant([])
setError(true)
} finally {
if (seq === requestSeqRef.current) setLoading(false)
}
}, [templateId, count, sourcePlanId, assetIds])
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
const retry = useCallback(() => {
setRetryNonce((n) => n + 1)
}, [])
useEffect(() => {
if (!enabled || !templateId || assetIds.length === 0 || count <= 1) {
requestSeqRef.current += 1
// 函数式更新:已是目标值时返回 prevObject.is 相等 React 跳过渲染),
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
setLoading((prev) => (prev === false ? prev : false))
setError((prev) => (prev === false ? prev : false))
lastKeyRef.current = ""
return
}
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
.sort()
.join(",")}`
if (key === lastKeyRef.current) return
lastKeyRef.current = key
load()
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
return {
clipsByVariant,
planIdsByVariant,
loading,
ready,
error,
retry,
}
}
export default useBatchVariantPlans
@@ -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/msg422 数组也兜底拼一下) */
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,
}
}
@@ -178,6 +178,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
isBatch && props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: []
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
const variantPlansArr =
isBatch && props.variantPlanIds?.length
? indexes.map((i) => props.variantPlanIds![i] || "")
: []
const hasVariantPlans = variantPlansArr.some((id) => !!id)
try {
const taskResp = await createGenerationTask({
@@ -200,6 +208,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
...(titlesArr.length ? { titles: titlesArr } : {}),
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
...(coversArr.length ? { cover_urls: coversArr } : {}),
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
...(props.titleSettings?.title
? {
title_config: {
@@ -44,6 +44,12 @@ interface UsePreviewAssetsReturn {
ready: boolean
/** 手动触发重新加载 */
reload: () => void
/**
* 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材
* (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并,
* 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。
*/
ensureAssets: (ids: string[]) => Promise<void>
}
/**
@@ -91,11 +97,41 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
}
}, [stableAssetIds, enabled])
// 补拉用独立序号:不干扰主 load 的竞态守卫
const ensureSeqRef = useRef(0)
const assetsRef = useRef<AssetItem[]>([])
useEffect(() => {
assetsRef.current = assets
}, [assets])
/**
* 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时,
* 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。
*/
const ensureAssets = useCallback(async (ids: string[]) => {
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
if (!validIds.length) return
const seq = ++ensureSeqRef.current
const missing = Array.from(new Set(validIds)).filter(
(id) => !assetsRef.current.some((a) => a.id === id),
)
if (!missing.length) return
const fetched = await fetchAssetsByIds(missing)
if (seq !== ensureSeqRef.current || !fetched.length) return
const existing = new Set(assetsRef.current.map((a) => a.id))
const additions = fetched.filter((a) => !existing.has(a.id))
if (!additions.length) return
const merged = [...assetsRef.current, ...additions]
assetsRef.current = merged
setAssets(merged)
setReady(merged.length > 0)
}, [])
useEffect(() => {
load()
}, [load])
return { assets, loading, ready, reload: load }
return { assets, loading, ready, reload: load, ensureAssets }
}
/**
@@ -15,6 +15,8 @@ export interface PlaybackSegment {
startTime: number
endTime: number
order: number
/** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */
playbackRate?: number
}
export interface SegmentSchedulerState {
@@ -131,6 +133,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (Math.abs(video.currentTime - localTime) > 0.05) {
video.currentTime = localTime
}
// #1754:按比例调速(配音时长≠clips 总时长时的前端兜底)
const rate = seg.playbackRate || 1
if (Math.abs(video.playbackRate - rate) > 0.01) {
video.playbackRate = rate
}
segIdxRef.current = index
setCurrentSegmentIndex(index)
@@ -242,6 +249,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
video.currentTime = seg.startTime
}
// #1754:调速
const rate = seg?.playbackRate || 1
if (Math.abs(video.playbackRate - rate) > 0.01) {
video.playbackRate = rate
}
try {
await video.play()
setIsPlaying(true)
@@ -1,297 +0,0 @@
/**
* 服务器渲染预览 Hook
*
* 核心职责:
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
* 5. 网络错误自动重试 2 次
*
* 状态机:
* idle → loading → ready → stale (config changed)
* ↘ failed → idle (retry)
*/
import { useState, useCallback, useRef, useEffect } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import type { CreatePreviewRequest } from "@/api/generation/types"
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
interface UseServerPreviewOptions {
/** 是否启用预览(Step4+ 且有素材和模板时) */
enabled: boolean
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
buildRequest: () => CreatePreviewRequest
/** 预览任务创建成功回调 */
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
}
interface UseServerPreviewReturn {
status: ServerPreviewStatus
videoUrl: string | null
error: string | null
/** 进度 0-100 */
progress: number
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
triggerPreview: () => void
/** 当前预览任务 ID */
taskId: string | null
}
const POLL_INTERVAL = 2000
const POLL_TIMEOUT = 120_000
const MAX_NETWORK_RETRIES = 2
/**
* 对配置参数做指纹,用于检测配置是否变化
*/
function buildFingerprint(req: CreatePreviewRequest): string {
return JSON.stringify({
t: req.template_id,
a: [...req.asset_ids].sort(),
d: req.duration,
r: req.video_ratio,
v: req.voice_library_id,
b: req.bgm_config,
title: req.title_config,
})
}
export function useServerPreview({
enabled,
buildRequest,
onPreviewTaskCreated,
}: UseServerPreviewOptions): UseServerPreviewReturn {
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
const [videoUrl, setVideoUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [taskId, setTaskId] = useState<string | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSeqRef = useRef(0)
const renderedFingerprintRef = useRef<string>("")
const mountedRef = useRef(true)
const networkRetriesRef = useRef(0)
// 始终持有最新的 buildRequest 和回调
const buildRequestRef = useRef(buildRequest)
buildRequestRef.current = buildRequest
const onCreatedRef = useRef(onPreviewTaskCreated)
onCreatedRef.current = onPreviewTaskCreated
/* ── 清理 ── */
const clearTimers = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearTimers()
}
}, [clearTimers])
/* ── 创建预览 + 轮询 ── */
const createAndPoll = useCallback(
async (request: CreatePreviewRequest, seq: number) => {
setStatus("loading")
setProgress(0)
setError(null)
networkRetriesRef.current = 0
try {
const resp = await createPreview(request)
if (seq !== requestSeqRef.current || !mountedRef.current) return
// 兼容批量响应 {items, total}:取第一个变体
const firstTask = resp.items?.[0]
const taskId = firstTask?.task_id || ""
setTaskId(taskId)
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
let completed = false
// 超时保护
timeoutTimerRef.current = setTimeout(() => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
completed = true
clearTimers()
setStatus("failed")
setError("预览渲染超时(120秒),请重试")
}, POLL_TIMEOUT)
const poll = async () => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(taskId)
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
completed = true
clearTimers()
renderedFingerprintRef.current = buildFingerprint(request)
setVideoUrl(st.video_url)
setProgress(100)
setStatus("ready")
setError(null)
return
}
if (st.status === "failed" || st.status === "cancelled") {
completed = true
clearTimers()
setStatus("failed")
setError(
st.status === "cancelled"
? "预览任务已取消"
: st.error_message || "预览渲染失败,请重试",
)
return
}
// pending / generating
if (typeof st.progress === "number") setProgress(st.progress)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
} catch (pollErr) {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
pollErr,
)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
} else {
completed = true
clearTimers()
setStatus("failed")
setError("网络错误,无法获取预览状态,请重试")
}
}
}
poll()
} catch (createErr) {
if (seq !== requestSeqRef.current || !mountedRef.current) return
console.error("[ServerPreview] 创建预览任务失败:", createErr)
const isNetworkError =
!!(createErr as { request?: unknown })?.request ||
(createErr as { code?: string })?.code === "ERR_NETWORK"
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
setTimeout(() => {
if (seq === requestSeqRef.current && mountedRef.current) {
createAndPoll(request, seq)
}
}, POLL_INTERVAL * 2)
return
}
const errData = (
createErr as { response?: { data?: { detail?: string; message?: string } } }
)?.response?.data
setStatus("failed")
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
}
},
[clearTimers],
)
/* ── 手动触发预览 ── */
const triggerPreview = useCallback(() => {
if (!enabled) return
const request = buildRequestRef.current()
if (!request.template_id || request.asset_ids.length === 0) return
clearTimers()
const seq = ++requestSeqRef.current
setVideoUrl(null)
setTaskId(null)
createAndPoll(request, seq)
}, [enabled, clearTimers, createAndPoll])
/* ── 自动触发 + 配置变更检测 ── */
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
const request = enabled ? buildRequest() : null
const currentFingerprint = request
? request.template_id && request.asset_ids.length > 0
? buildFingerprint(request)
: ""
: ""
// 首次进入自动触发
const didInitRef = useRef(false)
useEffect(() => {
if (!enabled || !currentFingerprint) {
didInitRef.current = false
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
requestSeqRef.current += 1
clearTimers()
return
}
if (!didInitRef.current) {
didInitRef.current = true
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
}
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
const prevFingerprintRef = useRef(currentFingerprint)
useEffect(() => {
if (!enabled || !currentFingerprint) return
const prev = prevFingerprintRef.current
prevFingerprintRef.current = currentFingerprint
if (!prev || prev === currentFingerprint) return
if (currentFingerprint === renderedFingerprintRef.current) return
// 配置已变更
// 判断是标题样式变更还是素材/配音/BGM 变更
const prevParsed = JSON.parse(prev) as Record<string, unknown>
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
const nonTitleChanged =
prevParsed.t !== currParsed.t ||
prevParsed.a !== currParsed.a ||
prevParsed.d !== currParsed.d ||
prevParsed.r !== currParsed.r ||
prevParsed.v !== currParsed.v ||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
if (nonTitleChanged) {
// 素材/配音/BGM/模板等变化 → 自动重新渲染
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
} else {
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
// 实时预览由 CSS TitleOverlay 提供
setStatus((s) => (s === "ready" ? "stale" : s))
}
}, [enabled, currentFingerprint, triggerPreview])
return {
status,
videoUrl,
error,
progress,
triggerPreview,
taskId,
}
}
export default useServerPreview
@@ -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)
@@ -0,0 +1,166 @@
/**
* 批量变体配音预览音频解析(#1750)
*
* 独立配音模式下每个变体挂载各自的配音 URL:
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
*
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
*/
import { useEffect, useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
import { previewTts } from "@/api/tts"
interface UseVariantVoicePreviewOptions {
/** 是否批量模式(count>1 */
enabled: boolean
/** 变体数量 */
count: number
/** 是否每个视频独立配音 */
perVideo: boolean
/** 共用配音 ID(独立模式下为变体 0 的配音) */
sharedVoiceId: string
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice */
clonedVoiceId?: string
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
variantVoiceIds: string[]
/** 各变体标题(TTS 合成文案源) */
titles: string[]
}
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
if (!id) return null
const m = materials.find((x) => x.id === id)
return m?.file_url || null
}
export function useVariantVoicePreview({
enabled,
count,
perVideo,
sharedVoiceId,
clonedVoiceId = "",
variantVoiceIds,
titles,
}: UseVariantVoicePreviewOptions): (string | null)[] {
const [urls, setUrls] = useState<(string | null)[]>([])
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
const { data: voiceMaterialsData } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
const ttsCacheRef = useRef<Map<string, string>>(new Map())
// 在途请求 AbortController
const abortRef = useRef<AbortController | null>(null)
const seqRef = useRef(0)
useEffect(() => {
if (!enabled || count <= 1) {
setUrls((prev) => (prev.length === 0 ? prev : []))
return
}
const seq = ++seqRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
// 每个变体实际使用的配音 ID
const voiceIds = Array.from({ length: count }, (_, i) =>
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
)
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
const result: (string | null)[] = voiceIds.map((id, i) => {
const materialUrl = findMaterialUrl(id, voiceMaterials)
if (materialUrl) return materialUrl
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
if (!perVideo && i === 0 && clonedVoiceId) {
return findMaterialUrl(clonedVoiceId, voiceMaterials)
}
return null
})
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
voiceIds.forEach((id, i) => {
if (result[i]) return
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
const title = titles[i] || ""
if (!ttsVoiceId || !title) return
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
})
setUrls((prev) =>
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
)
if (ttsJobs.length === 0) return
let cancelled = false
void (async () => {
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
for (const job of ttsJobs) {
const cacheKey = `${job.voiceId}|${job.title}`
const cached = ttsCacheRef.current.get(cacheKey)
if (cached) {
if (seq === seqRef.current) {
setUrls((prev) => {
if (prev[job.index] === cached) return prev
const next = [...prev]
next[job.index] = cached
return next
})
}
continue
}
try {
// eslint-disable-next-line no-await-in-loop
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
const audioUrl = res.audio_url || ""
if (audioUrl) {
ttsCacheRef.current.set(cacheKey, audioUrl)
setUrls((prev) => {
if (prev[job.index] === audioUrl) return prev
const next = [...prev]
next[job.index] = audioUrl
return next
})
}
} catch (err) {
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
}
}
})()
return () => {
cancelled = true
controller.abort()
}
}, [
enabled,
count,
perVideo,
sharedVoiceId,
clonedVoiceId,
variantVoiceIds,
titles,
voiceMaterials,
])
return urls
}
export default useVariantVoicePreview
@@ -1,54 +0,0 @@
/**
* 共享:根据素材列表和模板片段计算总视频时长
* GeneratePage(配音校验)和 FrontendPreviewPlayer(播放控制)共用
*/
export interface DurationAsset {
id?: string
duration?: number
metadata?: { duration?: number }
}
export interface DurationTemplateSegment {
duration_min?: number
duration_max?: number
}
/**
* 计算总视频时长
* @param assets 素材列表
* @param template 模板(含 segments
* @returns 总时长(秒),无有效数据时返回 0
*/
export function calculateTotalVideoDuration(
assets: DurationAsset[] | undefined,
template: { segments?: DurationTemplateSegment[] } | undefined,
): number {
if (!assets || assets.length === 0 || !template) return 0
const templateSegments = template.segments || []
return assets.reduce((sum, asset, i) => {
const assetDuration = asset.duration || asset.metadata?.duration || 30
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(
tplSeg.duration_max ?? assetDuration,
Math.max(tplSeg.duration_min ?? 0, assetDuration),
)
: Math.min(assetDuration, 10)
return sum + segDuration
}, 0)
}
/**
* 估算总视频时长(仅依赖模板 segments)
* 当素材未加载或加载失败时,用各片段 duration_max 之和作为估算值
* 确保配音时长校验不会因素材未就绪而跳过
*/
export function estimateTotalVideoDuration(
template: { segments?: DurationTemplateSegment[] } | undefined,
): number {
if (!template?.segments || template.segments.length === 0) return 0
return template.segments.reduce((sum, seg) => sum + (seg.duration_max || 0), 0)
}
@@ -66,7 +66,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} · ~{tpl.estimated_duration}s
{tpl.segments.length}
</Text>
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
</div>
@@ -57,11 +57,6 @@ export function useMyTemplates() {
title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config,
estimated_duration:
tpl.estimated_duration ??
Math.round(
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
),
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}),
onSuccess: () => {
@@ -1,7 +1,7 @@
/**
* 模板库页面(升级版)— V21 设计系统
* 对接后端模板管理 API
* - 分页查询(page/page_size/category/keyword/duration_range
* - 分页查询(page/page_size/category/keyword
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
* - 复制模板 / 从模板生成
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
@@ -24,7 +24,6 @@ const TemplateLibrary: React.FC = () => {
error,
searchText,
activeType,
durationRange,
page,
pageSize,
setPage,
@@ -34,7 +33,6 @@ const TemplateLibrary: React.FC = () => {
handleCreate,
handleSearchChange,
handleCategoryChange,
handleDurationChange,
} = useTemplateLibrary()
const {
@@ -62,8 +60,6 @@ const TemplateLibrary: React.FC = () => {
onSearchChange={handleSearchChange}
activeType={activeType}
onTypeChange={handleCategoryChange}
durationRange={durationRange}
onDurationChange={handleDurationChange}
/>
{/* 模板展示区 */}
@@ -77,7 +73,6 @@ const TemplateLibrary: React.FC = () => {
errorMessage={error?.message}
searchText={searchText}
activeType={activeType}
durationRange={durationRange}
onPageChange={setPage}
onPreview={handlePreview}
onToggleFavorite={toggleFavorite}
@@ -1,7 +1,7 @@
import React from "react"
import { Tag } from "antd"
import type { TemplateItem } from "@/api/templates"
import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary"
import { gradientForCategory, getTypeColor } from "../../utils/templateLibrary"
interface TemplateCardProps {
template: TemplateItem
@@ -37,7 +37,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(template.estimated_duration ?? template.target_duration)}
{template.clip_count || template.segments?.length || 0}
</span>
</div>
<div className="xx-template-preview-hint"></div>
@@ -5,9 +5,8 @@ import PreviewArea from "./template-detail-modal/PreviewArea"
import SegmentList from "./template-detail-modal/SegmentList"
import StyleConfig from "./template-detail-modal/StyleConfig"
import DetailFooter from "./template-detail-modal/DetailFooter"
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
import { getTypeColor } from "../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
@@ -18,7 +17,6 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
onCopy,
}) => {
const segments = template.segments ?? []
const totalSegmentDuration = calcTotalSegmentDuration(segments)
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
@@ -64,11 +62,6 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
size="small"
className="xx-template-modal-desc-table"
items={[
{
key: "duration",
label: "目标时长",
children: formatDuration(template.estimated_duration ?? template.target_duration),
},
{
key: "clips",
label: "片段数量",
@@ -87,7 +80,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
]}
/>
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
<SegmentList segments={segments} />
<StyleConfig template={template} />
<DetailFooter
template={template}
@@ -14,7 +14,6 @@ interface TemplateGridProps {
errorMessage?: string
searchText: string
activeType: string
durationRange: string
onPageChange: (page: number) => void
onPreview: (template: TemplateItem) => void
onToggleFavorite: (id: string, e: React.MouseEvent) => void
@@ -31,7 +30,6 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
errorMessage,
searchText,
activeType,
durationRange,
onPageChange,
onPreview,
onToggleFavorite,
@@ -64,7 +62,7 @@ export const TemplateGrid: React.FC<TemplateGridProps> = ({
// 空状态
if (templates.length === 0) {
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
const hasFilter = !!searchText || activeType !== "全部"
return (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">
@@ -1,15 +1,13 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import type { EditTemplateType, DurationRange } from "../../types/templateLibrary"
import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary"
import type { EditTemplateType } from "../../types/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
interface TemplateToolbarProps {
searchText: string
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
activeType: EditTemplateType | "全部"
onTypeChange: (type: EditTemplateType | "全部") => void
durationRange: DurationRange
onDurationChange: (value: DurationRange) => void
}
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
@@ -17,8 +15,6 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
onSearchChange,
activeType,
onTypeChange,
durationRange,
onDurationChange,
}) => {
return (
<div className="xx-templates-toolbar">
@@ -46,18 +42,8 @@ export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
</button>
))}
</div>
{/* 时长筛选 */}
<div className="xx-templates-duration-filter">
{DURATION_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
onClick={() => onDurationChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
export default TemplateToolbar
@@ -1,15 +1,13 @@
import React from "react"
import { Tooltip } from "antd"
import type { TemplateSegment } from "@/api/templates"
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
import { getMaterialTypeLabel, getTypeColor } from "../../../utils/templateLibrary"
interface SegmentListProps {
segments: TemplateSegment[]
totalDuration: number
}
/** 素材规则 / 片段列表 */
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
/** 素材规则 / 片段列表(#1750:模板只定片段数/结构,无时长概念) */
const SegmentList: React.FC<SegmentListProps> = ({ segments }) => {
if (segments.length === 0) return null
return (
@@ -35,17 +33,9 @@ const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) =>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalDuration))}
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
import type { EditTemplateType } from "../types/templateLibrary"
export const TEMPLATE_TYPES: Array<{
type: EditTemplateType | "全部"
@@ -15,16 +15,6 @@ export const TEMPLATE_TYPES: Array<{
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
]
export const DURATION_OPTIONS: Array<{
value: DurationRange
label: string
}> = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
@@ -9,7 +9,7 @@ import {
type TemplateItem,
type TemplateListParams,
} from "@/api/templates"
import type { EditTemplateType, DurationRange } from "../types/templateLibrary"
import type { EditTemplateType } from "../types/templateLibrary"
import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary"
export const useTemplateLibrary = () => {
@@ -19,7 +19,6 @@ export const useTemplateLibrary = () => {
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
const [durationRange, setDurationRange] = useState<DurationRange>("")
const [page, setPage] = useState(1)
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
@@ -31,9 +30,8 @@ export const useTemplateLibrary = () => {
}
if (activeType !== "全部") params.category = activeType
if (searchText.trim()) params.keyword = searchText.trim()
if (durationRange) params.duration_range = durationRange
return params
}, [page, pageSize, activeType, searchText, durationRange])
}, [page, pageSize, activeType, searchText])
/* 获取模板列表 */
const {
@@ -112,12 +110,6 @@ export const useTemplateLibrary = () => {
setPage(1)
}, [])
/* 切换时长筛选 */
const handleDurationChange = useCallback((value: DurationRange) => {
setDurationRange(value)
setPage(1)
}, [])
return {
/* 状态 */
templates,
@@ -127,7 +119,6 @@ export const useTemplateLibrary = () => {
error,
searchText,
activeType,
durationRange,
page,
pageSize,
/* mutations */
@@ -142,6 +133,5 @@ export const useTemplateLibrary = () => {
handleCreate,
handleSearchChange,
handleCategoryChange,
handleDurationChange,
}
}
@@ -2,7 +2,6 @@
export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"
/** 时长筛选值 */
export type DurationRange = "" | "short" | "medium" | "long"
/** 配置展示字段 */
export interface ConfigDisplayFields {
@@ -5,7 +5,6 @@ import {
DEFAULT_GRADIENT,
MATERIAL_TYPE_LABELS,
} from "../constants/templateLibrary"
import type { TemplateSegment } from "@/api/templates"
/** 获取类型对应颜色 */
export const getTypeColor = (type: string): string => {
@@ -18,16 +17,6 @@ export const gradientForCategory = (category: string): string => {
return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT
}
/** 格式化时长 */
export const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒"
const totalSec = Math.round(seconds)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}`
return `${m}${s > 0 ? `${s}` : ""}`
}
/** 格式化配置对象为可读文本 */
export const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认"
@@ -47,8 +36,3 @@ export const getMaterialTypeLabel = (materialType: string | null | undefined): s
if (!materialType) return "不限"
return MATERIAL_TYPE_LABELS[materialType] ?? materialType
}
/** 计算片段总时长(取每个片段 min/max 的平均值) */
export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => {
return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0)
}
@@ -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,176 @@
/**
* CanvasPreviewGrid 单测(Issue #1741 / #1750
*
* 验证:
* - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中)
* - 每个变体都拿到各自的后端真实片段 variantClips[i]serverClips),playToken 为 0 基变体序号
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
* - 实例上报暂停(null)→ 播放权释放
* - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器),
* 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips
* - #1750 加载态:渲染加载占位,不渲染播放器
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, fireEvent, screen } from "@testing-library/react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
default: (props: Record<string, unknown>) => {
playerCalls.push(props)
const token = props.playToken as number
const active = props.activePlayToken as number | null
const change = props.onPlayTokenChange as (t: number | null) => void
return (
<div data-testid={`player-${token}`}>
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
<button type="button" onClick={() => change(token)}>
play-{token}
</button>
<button type="button" onClick={() => change(null)}>
pause-{token}
</button>
</div>
)
},
}))
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10 },
duration: 10,
}
}
function makeClips(variant: number): EditPlanClip[] {
return ["a1", "a2"].map((assetId, order) => ({
id: `clip-v${variant}-${assetId}`,
plan_id: `plan-${variant}`,
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}))
}
const titleSettings = {
size: 36,
font: "思源黑体",
color: "#fff",
position: "bottom" as const,
bold: false,
italic: false,
stroke: true,
shadow: false,
posX: null,
posY: null,
}
const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)]
function renderGrid(overrides: Record<string, unknown> = {}) {
playerCalls.length = 0
return render(
<CanvasPreviewGrid
count={3}
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
videoRatio="9:16"
titles={["标题1", "标题2", "标题3"]}
titleSettings={titleSettings}
variantClips={variantClips}
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
selectedIds={[0, 1, 2]}
onToggleSelect={() => {}}
{...overrides}
/>,
)
}
describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => {
beforeEach(() => {
playerCalls.length = 0
})
it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => {
renderGrid()
expect(playerCalls).toHaveLength(3)
playerCalls.forEach((p, i) => {
expect(p.playToken).toBe(i)
expect(p.serverClips).toBe(variantClips[i])
expect(p.variantTitle).toBe(`标题${i + 1}`)
})
// 独立配音:变体 0/2 有各自 URL;变体 1 为 nullTTS 合成中)→ 传 undefined
expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3")
expect(playerCalls[1].voiceAudioUrl).toBeUndefined()
expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3")
// 配音状态徽标
expect(screen.getByTestId("voice-0").textContent).toBe("has-voice")
expect(screen.getByTestId("voice-1").textContent).toBe("no-voice")
expect(screen.getByTestId("voice-2").textContent).toBe("has-voice")
})
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
renderGrid()
playerCalls.forEach((p) => {
expect(p.activePlayToken).toBeNull()
expect(typeof p.onPlayTokenChange).toBe("function")
})
})
it("点击变体2播放:所有实例 activePlayToken 变为 10 基,其他实例自动暂停)", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-1").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("1")
expect(screen.getByTestId("token-1").textContent).toBe("1")
expect(screen.getByTestId("token-2").textContent).toBe("1")
})
it("正在播放实例上报暂停后,播放权释放(token 回 null", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-2").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("2")
fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1])
expect(screen.getByTestId("token-2").textContent).toBe("none")
})
it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => {
const onRetry = vi.fn()
renderGrid({ clipsError: true, onRetryClips: onRetry })
expect(screen.getAllByRole("alert")).toHaveLength(3)
expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3)
// 严禁假数据:错误态不渲染播放器
expect(playerCalls).toHaveLength(0)
// 重试按钮仅变体 0 卡片出现
const retryButtons = screen.getAllByText("重试")
expect(retryButtons).toHaveLength(1)
fireEvent.click(retryButtons[0])
expect(onRetry).toHaveBeenCalledTimes(1)
})
it("clipsLoading:渲染加载占位,不渲染播放器", () => {
renderGrid({ clipsLoading: true })
expect(screen.getAllByText("独立选片中…")).toHaveLength(3)
expect(screen.queryAllByRole("alert")).toHaveLength(0)
expect(playerCalls).toHaveLength(0)
})
})
@@ -0,0 +1,213 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750
*
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
* - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause
* - 点击播放/暂停时上报播放权(onPlayTokenChange
* - #1750serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const mocks = vi.hoisted(() => ({
isPlaying: false,
pause: vi.fn(),
togglePlayPause: vi.fn(),
}))
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
// segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips);
// canPlay 与真实 hook 一致:无片段时不可播放
useSegmentScheduler: vi.fn((segments: unknown[]) => ({
isPlaying: mocks.isPlaying,
currentTime: 0,
totalDuration: 20,
currentSegmentIndex: 0,
canPlay: segments.length > 0,
togglePlayPause: mocks.togglePlayPause,
seekTo: vi.fn(),
pause: mocks.pause,
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
})),
}))
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
useCanvasPlayer: () => ({
state: {
isPlaying: false,
isReady: false,
isBuffering: false,
currentTime: 0,
duration: 0,
errorMessage: "",
hasDecodeError: false,
},
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
}),
}))
beforeEach(() => {
mocks.isPlaying = false
mocks.pause.mockClear()
mocks.togglePlayPause.mockClear()
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10, width: 1080, height: 1920 },
duration: 10,
}
}
function makeClip(assetId: string, order: number): EditPlanClip {
return {
id: `clip-${assetId}`,
plan_id: "plan-1",
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}
}
const serverClips = [makeClip("a1", 0), makeClip("a2", 1)]
const baseProps = {
assets: [makeAsset("a1"), makeAsset("a2")],
videoRatio: "9:16",
ready: true,
serverClips,
}
function videos(): HTMLVideoElement[] {
return Array.from(document.querySelectorAll("video"))
}
describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => {
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
expect(videos()).toHaveLength(2)
videos().forEach((v) => expect(v.muted).toBe(true))
})
it("无配音时 video 不 muted(素材原声兜底)", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
const vs = videos()
expect(vs[0].muted).toBe(false)
fireEvent.click(screen.getByRole("button", { name: "静音" }))
videos().forEach((v) => expect(v.muted).toBe(true))
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
mocks.isPlaying = true
const { rerender } = render(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).not.toHaveBeenCalled()
// 播放权切给实例 2(0 基 token)
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={2}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
// token 切回自己:不重复暂停
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
})
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
// mocks.isPlaying = falsebeforeEach 重置)
const { rerender } = render(
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
)
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
expect(mocks.pause).not.toHaveBeenCalled()
})
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
const onToken = vi.fn()
render(
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
)
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
expect(playButtons.length).toBeGreaterThanOrEqual(1)
fireEvent.click(playButtons[0])
expect(onToken).toHaveBeenCalledWith(2)
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
})
it("每个卡片都渲染独立静音按钮", () => {
render(
<div>
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
</div>,
)
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
})
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
expect(videos()).toHaveLength(0)
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
})
})
@@ -0,0 +1,40 @@
/**
* 智能匹配候选池数量规则单测(#1744/#1750
*
* #1750 基线:模板只有片段数/结构、没有时长概念——候选池纯按「片段数 × 3」
* (每个片段至少 3 个候选供变体间洗牌避让),不再参考任何 duration 字段;
* 结果钳制到 [1, 200](后端 limit 上限 200);无片段信息时兜底 30。
*/
import { describe, it, expect } from "vitest"
import { computeLimitFromSegments } from "@/pages/generate/hooks/step2-materials/useSmartMatch"
import type { TemplateSegment } from "@/api/templates/types"
function segs(n: number): TemplateSegment[] {
// 新规则只看片段数量,duration 字段已废弃,这里不再构造
return Array.from({ length: n }, () => ({}) as TemplateSegment)
}
describe("computeLimitFromSegments (#1750 候选池 = 片段数×3", () => {
it("无 segments 时返回兜底 30", () => {
expect(computeLimitFromSegments(undefined)).toBe(30)
expect(computeLimitFromSegments([])).toBe(30)
})
it("候选池 = 片段数 × 3(与任何时长字段无关)", () => {
expect(computeLimitFromSegments(segs(1))).toBe(3)
expect(computeLimitFromSegments(segs(3))).toBe(9)
expect(computeLimitFromSegments(segs(5))).toBe(15)
expect(computeLimitFromSegments(segs(10))).toBe(30)
})
it("超过上限钳制为 200(后端 limit 上限)", () => {
expect(computeLimitFromSegments(segs(67))).toBe(200) // 67×3=201 → 200
expect(computeLimitFromSegments(segs(100))).toBe(200) // 100×3=300 → 200
})
it("任何正常片段数下 limit 均 ≥ 1", () => {
for (let n = 0; n <= 10; n++) {
expect(computeLimitFromSegments(segs(n))).toBeGreaterThanOrEqual(1)
}
})
})
@@ -5,7 +5,6 @@
*
* 重构记录:
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
* - Phase1 预览生成:新增 generation API 层 + PreviewVideoPanel
*/
import { describe, it, expect } from "vitest"
@@ -23,8 +22,6 @@ import "@/pages/generate/components/Step3VoiceWithMode"
import "@/pages/generate/components/CanvasPreviewGrid"
import "@/pages/generate/components/BatchGenerationGrid"
import "@/pages/generate/components/PreviewCountModal"
import "@/pages/generate/components/PreviewVideoPanel"
import "@/pages/generate/components/GenerateResultPanel"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/components/voice/VoiceRecommendSection"
import "@/pages/generate/components/voice/VoiceChoiceCard"
@@ -50,6 +47,8 @@ describe("GeneratePage module smoke test", () => {
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/useBatchCovers"
import "@/pages/generate/hooks/useBatchVariantPlans"
import "@/pages/generate/hooks/useVariantVoicePreview"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
@@ -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,230 @@
/**
* useBatchVariantPlans 单测(#1750 修正:严禁假数据冒充预览)
* - 批量(N>1)时向后端申请变体计划,返回 clips/planIds
* - 404(端点未上线)/ 400(参数错误)/ 网络错误:一律 error=trueUI 可重试
* - 数据不完整(某变体 clips 为空):error=true
* - retry() 重新发起请求,成功后 ready=true
* - N=1 / 无素材 / disabled 不发请求
* - 入参变化重新申请;未变化不重复请求
*/
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { useBatchVariantPlans } from "@/pages/generate/hooks/useBatchVariantPlans"
import type { EditPlanClip } from "@/api/template-editor"
vi.mock("@/api/generation/variantPlans", () => ({
createBatchVariantPlans: vi.fn(),
}))
import { createBatchVariantPlans } from "@/api/generation/variantPlans"
const mockCreate = vi.mocked(createBatchVariantPlans)
function makeClip(
partial: Partial<EditPlanClip> & { asset_id: string; order: number },
): EditPlanClip {
return {
id: `clip-${partial.asset_id}-${partial.order}`,
plan_id: "plan-x",
clip_type: "main",
start_time: 0,
duration: 5,
text_content: "",
transition_effect: "",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
...partial,
}
}
function buildResp(count: number) {
return {
total: count,
items: Array.from({ length: count }, (_, i) => ({
variant_index: i,
plan_id: `plan-${i}`,
clips: [makeClip({ asset_id: `a${(i % 3) + 1}`, order: 0, start_time: i * 2 })],
})),
}
}
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockReset()
})
describe("useBatchVariantPlans (#1750 错误态)", () => {
it("批量 N=3 时申请变体计划并返回按索引对齐的 clips/planIds", async () => {
mockCreate.mockResolvedValueOnce(buildResp(3))
const { result } = renderHook(() =>
useBatchVariantPlans({
enabled: true,
count: 3,
templateId: "tpl-1",
assetIds: ["a1", "a2", "a3"],
sourcePlanId: "plan-src",
}),
)
await waitFor(() => expect(result.current.ready).toBe(true))
expect(mockCreate).toHaveBeenCalledTimes(1)
expect(mockCreate).toHaveBeenCalledWith({
template_id: "tpl-1",
asset_ids: ["a1", "a2", "a3"],
count: 3,
source_edit_plan_id: "plan-src",
})
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1", "plan-2"])
expect(result.current.clipsByVariant[1]?.[0]?.start_time).toBe(2)
expect(result.current.error).toBe(false)
expect(result.current.loading).toBe(false)
})
it("sourcePlanId 为空时不传 source_edit_plan_id 字段", async () => {
mockCreate.mockResolvedValueOnce(buildResp(2))
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.ready).toBe(true))
const arg = mockCreate.mock.calls[0][0]
expect(arg).not.toHaveProperty("source_edit_plan_id")
})
it("404(后端端点未上线)→ error=true,不再静默降级", async () => {
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
expect(result.current.clipsByVariant).toEqual([])
})
it("400(参数错误)→ error=true", async () => {
mockCreate.mockRejectedValueOnce({ response: { status: 400, data: { detail: "参数错误" } } })
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
})
it("网络错误 → error=true", async () => {
mockCreate.mockRejectedValue(new Error("Network Error"))
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
})
it("某变体 clips 为空(数据不完整)→ error=true,严禁假数据冒充", async () => {
mockCreate.mockResolvedValueOnce({
total: 2,
items: [
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
{ variant_index: 1, plan_id: "plan-1", clips: [] },
],
})
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBe(true)
expect(result.current.ready).toBe(false)
expect(result.current.clipsByVariant).toEqual([])
})
it("retry() 失败后重试:成功则 error 清除、ready=true", async () => {
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.error).toBe(true))
mockCreate.mockResolvedValueOnce(buildResp(3))
act(() => {
result.current.retry()
})
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.error).toBe(false)
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it("N=1 不发请求(单视频零回归)", () => {
renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 1, templateId: "tpl-1", assetIds: ["a1"] }),
)
expect(mockCreate).not.toHaveBeenCalled()
})
it("无素材/enabled=false 不发请求", () => {
const { rerender } = renderHook(
(props: { enabled: boolean; ids: string[] }) =>
useBatchVariantPlans({
enabled: props.enabled,
count: 3,
templateId: "tpl-1",
assetIds: props.ids,
}),
{ initialProps: { enabled: true, ids: [] as string[] } },
)
expect(mockCreate).not.toHaveBeenCalled()
rerender({ enabled: false, ids: ["a1"] })
expect(mockCreate).not.toHaveBeenCalled()
})
it("响应乱序/越界 variant_index:越界丢弃、其余按索引对齐", async () => {
mockCreate.mockResolvedValueOnce({
total: 2,
items: [
{ variant_index: 99, plan_id: "plan-bad", clips: [makeClip({ asset_id: "a9", order: 0 })] },
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a2", order: 0 })] },
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
],
})
const { result } = renderHook(() =>
useBatchVariantPlans({
enabled: true,
count: 2,
templateId: "tpl-1",
assetIds: ["a1", "a2"],
}),
)
await waitFor(() => expect(result.current.ready).toBe(true))
// 越界 99 丢弃;乱序返回的 0/1 按索引对齐
expect(result.current.error).toBe(false)
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1"])
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
expect(result.current.clipsByVariant[1]?.[0]?.asset_id).toBe("a2")
})
it("非 ready 状态的 clip 被过滤;过滤后每变体仍有片段则 ready", async () => {
mockCreate.mockResolvedValueOnce({
total: 2,
items: [
{
variant_index: 0,
plan_id: "plan-0",
clips: [
makeClip({ asset_id: "a1", order: 0 }),
makeClip({ asset_id: "a2", order: 1, status: "pending" }),
],
},
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a3", order: 0 })] },
],
})
const { result } = renderHook(() =>
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
)
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.error).toBe(false)
expect(result.current.clipsByVariant[0]).toHaveLength(1)
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
})
})
@@ -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,
+14 -4
View File
@@ -34,7 +34,9 @@ def create_video_record_and_dedup(
fps: float = 25.0,
name: str = "",
thumbnail_url: str = "",
) -> int:
) -> dict:
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
采用两阶段持久化:先计算所有指纹/查重数据(内存),
@@ -76,6 +78,7 @@ def create_video_record_and_dedup(
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
deduplicator = VideoDeduplicator()
fingerprint = None
batch_similarity: float | None = None
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
@@ -105,9 +108,11 @@ def create_video_record_and_dedup(
)
# (b) 批次内查重(仅当有 batch_id 时)
batch_similarity: float | None = None
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
batch_similarity = float(duplicate_result.get("similarity", 0.0))
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
@@ -161,7 +166,12 @@ def create_video_record_and_dedup(
generated_video.is_duplicate,
generated_video.duplicate_rate,
)
return 1
return {
"video_count": 1,
"is_duplicate": bool(generated_video.is_duplicate),
"batch_similarity": batch_similarity,
"duplicate_of": generated_video.duplicate_of,
}
except Exception as e:
logger.error(
"Failed to create video record / dedup for task %s: %s",
@@ -169,4 +179,4 @@ def create_video_record_and_dedup(
e,
)
session.rollback()
return 0
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
+11 -4
View File
@@ -16,6 +16,9 @@ from typing import Any
from shared.ffmpeg_utils import ( # noqa: F401
DEFAULT_FFMPEG_TIMEOUT,
FFMPEG_BIN,
FFMPEG_ENCODE_CRF,
FFMPEG_ENCODE_PRESET,
FFMPEG_ENCODE_THREADS,
FFPROBE_BIN,
run_ffmpeg,
)
@@ -291,9 +294,11 @@ def normalize_video(
"-c:v",
"libx264",
"-crf",
"23",
FFMPEG_ENCODE_CRF,
"-preset",
"medium",
FFMPEG_ENCODE_PRESET,
"-threads",
FFMPEG_ENCODE_THREADS,
"-c:a",
"aac",
"-b:a",
@@ -404,9 +409,11 @@ def random_edge_crop(
"-c:v",
"libx264",
"-preset",
"fast",
FFMPEG_ENCODE_PRESET,
"-crf",
"18",
FFMPEG_ENCODE_CRF,
"-threads",
FFMPEG_ENCODE_THREADS,
"-c:a",
"copy",
"-movflags",
+3 -2
View File
@@ -9,6 +9,7 @@ from dataclasses import dataclass
from typing import List
import ffmpeg
from shared.ffmpeg_utils import FFMPEG_ENCODE_CRF, FFMPEG_ENCODE_PRESET
logger = logging.getLogger(__name__)
@@ -90,8 +91,8 @@ class VideoProcessor:
acodec="aac",
s=f"{width}x{height}",
r=fps,
preset="medium",
crf=23,
preset=FFMPEG_ENCODE_PRESET,
crf=int(FFMPEG_ENCODE_CRF),
)
.overwrite_output()
.run(capture_stdout=True, capture_stderr=True)
@@ -599,7 +599,7 @@ class RenderAdapter:
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
str(result.output_path), plan_id, task_id=job_id, num_frames=5, title_text=""
)
if cover_candidates:
logger.info(
+55 -13
View File
@@ -47,13 +47,23 @@ class RenderContext:
def clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长.
"""计算 clip 的有效时长#1749:目标段长始终为准)。
与 UnifiedRenderService._clip_effective_duration 逻辑一致
与 UnifiedRenderService._clip_effective_duration 逻辑一致;素材短于段长的
部分由末帧冻结 tpad / 音频 apad 铺满,不在此处钳制。
"""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
return float(clip.duration)
return float(clip.actual_duration) if clip.actual_duration > 0 else 0.0
def _clip_freeze_seconds(clip: ResolvedClip) -> float:
"""读取 #1749 末帧冻结秒数(_resolve_clips 写入 config['_freeze_seconds'])。"""
cfg = getattr(clip, "config", None) or {}
try:
return max(0.0, float(cfg.get("_freeze_seconds", 0.0) or 0.0))
except (TypeError, ValueError):
return 0.0
def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
@@ -273,6 +283,9 @@ def concat_main_audio(
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
# #1749 末帧冻结秒数(音频需 apad 补静音与视频等长)
freeze_seconds = _clip_freeze_seconds(clip)
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
has_reverse = reverse_config.enabled and reverse_config.reverse_audio
@@ -281,20 +294,28 @@ def concat_main_audio(
if not has_speed and not has_reverse:
# 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。
vol = _clip_volume(clip)
need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
need_trim = (
trim_start > 0 or freeze_seconds > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
)
need_volume = abs(vol - 1.0) >= 1e-6
if need_trim:
# 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。
# 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)
# 滤镜顺序:atrim → asetpts → apad(冻结补静音) → volume
af_parts: list[str] = []
if trim_start > 0 and effective_duration > 0:
af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}")
# 冻结场景:素材内可用时长 = 目标段长 − freeze(截掉超出素材的部分后补静音)
atrim_dur = final_duration
if freeze_seconds > 0:
atrim_dur = max(0.0, final_duration - freeze_seconds)
if trim_start > 0 and atrim_dur > 0:
af_parts.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
elif trim_start > 0:
af_parts.append(f"atrim=start={trim_start:.3f}")
elif final_duration > 0:
af_parts.append(f"atrim=duration={final_duration:.3f}")
elif atrim_dur > 0:
af_parts.append(f"atrim=duration={atrim_dur:.3f}")
af_parts.append("asetpts=PTS-STARTPTS")
if freeze_seconds > 0:
af_parts.append(f"apad=whole_dur={final_duration:.3f}")
if need_volume:
af_parts.append(f"volume={vol:.4f}")
command = [
@@ -334,8 +355,13 @@ def concat_main_audio(
"-ac",
"2",
]
af_simple: list[str] = []
if need_volume:
command.extend(["-af", f"volume={vol:.4f}"])
af_simple.append(f"volume={vol:.4f}")
if freeze_seconds > 0:
af_simple.append(f"apad=whole_dur={final_duration:.3f}")
if af_simple:
command.extend(["-af", ",".join(af_simple)])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
@@ -369,6 +395,10 @@ def concat_main_audio(
if abs(vol - 1.0) >= 1e-6:
audio_filters.append(f"volume={vol:.4f}")
# #1749 冻结补静音(调速后时长已变,apad 补齐到最终时长)
if freeze_seconds > 0:
audio_filters.append(f"apad=whole_dur={final_duration:.3f}")
# aformat 归一化:统一输出格式为 48000Hz + stereo + fltp
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
@@ -410,10 +440,13 @@ def concat_main_audio(
speed = getattr(clip, "playback_speed", 1.0) or 1.0
if not isinstance(speed, (int, float)) or speed <= 0:
speed = 1.0
# #1749 末帧冻结:素材内可用时长 = 目标段长 − freeze,apad 补静音
freeze_seconds = _clip_freeze_seconds(clip)
atrim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
audio_filters: list[str] = []
if effective_duration > 0:
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频调速 — atempo 多级串联
@@ -435,6 +468,10 @@ def concat_main_audio(
if reverse_filter:
audio_filters.append(reverse_filter)
# #1749 冻结补静音:视频 tpad 延长后音频等长补齐(concat 时间轴对齐)
if freeze_seconds > 0:
audio_filters.append(f"apad=whole_dur={effective_duration:.3f}")
# 音量(0=静音,1=原声)
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
@@ -506,12 +543,17 @@ def mix_with_independent_audio(
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
freeze_seconds = _clip_freeze_seconds(clip)
atrim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
clip_filters = []
if effective_duration > 0:
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
clip_filters.append(f"atrim=start={trim_start:.3f}:duration={atrim_dur:.3f}")
clip_filters.append("asetpts=PTS-STARTPTS")
else:
clip_filters.append("asetpts=PTS-STARTPTS")
# #1749 冻结补静音(与视频 tpad 等长)
if freeze_seconds > 0:
clip_filters.append(f"apad=whole_dur={effective_duration:.3f}")
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
clip_filters.append(f"volume={vol:.4f}")
@@ -279,21 +279,25 @@ def extract_and_upload_cover_frames(
plan_id: str,
*,
task_id: str = "",
num_frames: int = 3,
num_frames: int = 5, # 抽 5 帧候选,通过质量评分选出最佳帧
title_text: str = "",
title_color: str = "#ffffff",
title_position: str = "bottom",
title_font_size: int | None = None,
) -> list[dict]:
"""从视频中抽取多帧作为封面候选,上传到 OSS。
"""从视频中抽取多帧作为封面候选,通过质量评分选出最佳帧,上传到 OSS。
优先使用 MediaKit 智能抽帧,失败时降级到 ffmpeg 直接抽帧。
流程:
1. 优先使用 MediaKit 智能抽帧(多抽一些供选择)
2. MediaKit 不足时降级到 ffmpeg 均匀抽帧
3. 对所有候选帧进行质量评分(清晰度/亮度/色彩丰富度)
4. 按分数从高到低排序返回
Args:
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
num_frames: 抽取帧数(默认 3
num_frames: 抽取候选帧数(默认 5,通过质量评分选出最佳帧
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
title_color: 标题字体颜色(#RRGGBB
@@ -301,7 +305,7 @@ def extract_and_upload_cover_frames(
title_font_size: 标题字号,None 时自动计算
Returns:
封面候选列表,每项包含 {"url": str, "position": float}
封面候选列表(按质量分数降序),每项包含 {"url": str, "position": float, "score": float}
"""
import httpx
from video_processing.ffmpeg_utils import probe_duration
@@ -313,80 +317,125 @@ def extract_and_upload_cover_frames(
duration = 0.0
candidates: list[dict] = []
_temp_paths: list[str] = [] # 收集所有临时文件路径,最后统一清理
# 优先尝试 MediaKit 智能抽帧
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
# 下载 MediaKit 返回的帧图
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
try:
# ── 阶段 1:抽帧 ──────────────────────────────────────────────
# 优先尝试 MediaKit 智能抽帧
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
# 下载 MediaKit 返回的帧图
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
# 叠加标题文字(如需要)
if title_text and title_text.strip():
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
# 叠加标题文字(如需要)
if title_text and title_text.strip():
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
candidates.append(
{
"url": url,
"position": round(seek_time, 2),
"image_path": tmp.name,
}
)
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d", len(candidates))
else:
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
# Fallback: ffmpeg 直接抽帧(仅当 MediaKit 不足时)
if len(candidates) < num_frames:
logger.info("[thumbnail] 使用 ffmpeg 抽帧补充")
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
frame_path = extract_first_frame(
video_path,
output_path=tmp.name,
seek_ratio=ratio,
min_seek_seconds=0.5,
)
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
if title_text and title_text.strip():
apply_title_overlay(
frame_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
candidates.append(
{
"url": url,
"position": round(seek_time, 2),
"image_path": tmp.name,
}
)
except Exception as e:
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
candidates.append({"url": url, "position": round(seek_time, 2)})
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
finally:
Path(tmp.name).unlink(missing_ok=True)
# ── 阶段 2:质量评分 ────────────────────────────────────────────
if len(candidates) > 1:
try:
from packages.shared.cover_frame_scorer import score_frames
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d", len(candidates))
return candidates[:num_frames]
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
# Fallback: ffmpeg 直接抽帧
logger.info("[thumbnail] 使用 ffmpeg 抽帧")
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
frame_path = extract_first_frame(
video_path,
output_path=tmp.name,
seek_ratio=ratio,
min_seek_seconds=0.5,
)
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
if title_text and title_text.strip():
apply_title_overlay(
frame_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
candidates = score_frames(candidates)
logger.info(
"[thumbnail] 封面帧质量评分完成: plan_id=%s count=%d best_score=%.1f",
plan_id,
len(candidates),
candidates[0].get("score", 0.0) if candidates else 0.0,
)
except Exception:
logger.warning(
"[thumbnail] 封面帧质量评分失败,保持原始顺序: plan_id=%s",
plan_id,
exc_info=True,
)
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
candidates.append({"url": url, "position": round(seek_time, 2)})
except Exception as e:
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
finally:
Path(tmp.name).unlink(missing_ok=True)
return candidates
# ── 阶段 3:清理临时文件 ────────────────────────────────────────
# 移除 image_path(不再需要),但临时文件统一清理
for c in candidates:
c.pop("image_path", None)
return candidates
finally:
# 统一清理所有临时文件
for path in _temp_paths:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
@@ -35,6 +35,9 @@ from video_processing.ffmpeg_utils import (
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
FFMPEG_BIN,
FFMPEG_ENCODE_CRF,
FFMPEG_ENCODE_PRESET,
FFMPEG_ENCODE_THREADS,
probe_duration,
probe_has_audio,
probe_video_info,
@@ -518,120 +521,45 @@ class UnifiedRenderService:
layers: list[RenderLayer],
voice_duration: float,
) -> None:
"""调整片段时长以对齐配音时长
"""#1749:配音对齐仅保留 ±5% 守卫日志,不再做全局裁剪/慢放
核心逻辑:
- 计算片段总时长配音时长的比例
- ±5% 以内不调整
- ratio < 1(片段比配音长):按比例裁剪每段末尾
- ratio > 1(片段比配音短):按比例慢放每段
Args:
layers: 渲染图层列表
voice_duration: 配音时长(秒)
段长已在选片阶段由 voice_duration_planner 按配音时长精确分配
(成片总时长=配音,素材不足走末帧冻结 tpad/apad)。渲染期任何全局
裁剪会截断配音、全局慢放会导致失真——两者均已删除。此处仅在偏差
超阈值时记录 warning 供排查,不修改任何 clip。
"""
if voice_duration <= 0:
return
# 只调整视频图层(main/broll/background),不调整音频图层
video_layers = [layer for layer in layers if layer.role in ("main", "broll", "background")]
if not video_layers:
return
# 计算所有视频图层的总时长
total_clips_duration = 0.0
for layer in video_layers:
for clip in layer.clips:
clip_dur = self._clip_adjusted_duration(clip)
total_clips_duration += clip_dur
total_clips_duration += self._clip_adjusted_duration(clip)
if total_clips_duration <= 0:
return
ratio = voice_duration / total_clips_duration
# ±5% 以内不调整
if abs(ratio - 1.0) <= 0.05:
logger.info(
"[voice-align] 比例接近1:1,跳过调整: ratio=%.4f voice=%.3f clips=%.3f",
estimated = self._estimate_total_duration(video_layers)
ratio = voice_duration / total_clips_duration if total_clips_duration > 0 else 1.0
if abs(ratio - 1.0) > 0.05:
logger.warning(
"[voice-align] 段长合计与配音偏差>5%%(仅守卫,不裁剪/不慢放): "
"ratio=%.4f voice=%.3f clips_sum=%.3f estimated_final=%.3f",
ratio,
voice_duration,
total_clips_duration,
estimated,
)
return
logger.info(
"[voice-align] 开始调整片段时长: ratio=%.4f voice=%.3f clips=%.3f",
ratio,
voice_duration,
total_clips_duration,
)
# 收集所有视频 clip
all_clips: list[tuple[RenderLayer, ResolvedClip]] = []
for layer in video_layers:
for clip in layer.clips:
all_clips.append((layer, clip))
if not all_clips:
return
if ratio < 1.0:
# 片段比配音长,按比例裁剪每段末尾
# 减少每个 clip 的 duration
for _layer, clip in all_clips:
old_duration = clip.duration if clip.duration > 0 else clip.actual_duration
new_duration = old_duration * ratio
# 更新 duration
clip.duration = max(0.1, new_duration) # 至少 0.1s
# 如果有 trim_config,也需要调整
if clip.trim_config is not None:
new_trim_duration = clip.trim_config.duration * ratio
clip.trim_config = TrimConfig(
start_time=clip.trim_config.start_time,
duration=max(0.1, new_trim_duration),
)
logger.debug(
"[voice-align] trim clip=%s: %.3f -> %.3f",
clip.clip_id,
old_duration,
clip.duration,
)
else:
# ratio > 1.0: 片段比配音短,按比例慢放每段
# 降低 playback_speed
for _layer, clip in all_clips:
old_speed = clip.playback_speed if clip.playback_speed > 0 else 1.0
# speed = old_speed / ratio 会使视频变慢(ratio > 1 时)
new_speed = old_speed / ratio
# 下限 0.25x(避免过慢)
new_speed = max(0.25, round(new_speed, 4))
clip.playback_speed = new_speed
logger.debug(
"[voice-align] slowdown clip=%s: speed %.4f -> %.4f",
clip.clip_id,
old_speed,
new_speed,
)
# 调整后重新计算总时长用于日志
new_total = 0.0
for layer in video_layers:
for clip in layer.clips:
new_total += self._clip_adjusted_duration(clip)
logger.info(
"[voice-align] 调整完成: 新总时长=%.3fs (目标=%.3fs, 差异=%.3fs)",
new_total,
voice_duration,
abs(new_total - voice_duration),
)
logger.info(
"[voice-align] 段长与配音偏差≤5%%,无需处理: ratio=%.4f voice=%.3f estimated=%.3f",
ratio,
voice_duration,
estimated,
)
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。
@@ -1372,15 +1300,26 @@ class UnifiedRenderService:
# trim
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
# #1749 冻结时素材内截取时长 = 目标段长 − freeze
trim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
if effective_duration > 0:
filters.append(f"trim=duration={effective_duration}")
if trim_dur > 0:
filters.append(f"trim=duration={trim_dur}")
filters.append("setpts=PTS-STARTPTS")
# #1749 末帧冻结(禁慢放)
if freeze_seconds > 0:
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
# 调速 — 与 filter_complex 路径一致
# 视觉扰动(plan 级别,直通模式同样适用)
vp = self._get_visual_perturbation()
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{speed:.4f}")
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
effective_speed = speed * vp_speed
if abs(effective_speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{effective_speed:.4f}")
# 倒放滤镜
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
@@ -1389,6 +1328,10 @@ class UnifiedRenderService:
if reverse_filter:
filters.append(reverse_filter)
# 视觉扰动:hflip(在 scale 之前)
if vp:
self._apply_visual_perturbation_pre_scale(filters, vp)
# scale + pad(等比缩放+留黑边)
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
@@ -1403,6 +1346,10 @@ class UnifiedRenderService:
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
if vp:
self._apply_visual_perturbation_post_scale(filters, vp)
# 调色滤镜
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
@@ -1424,6 +1371,10 @@ class UnifiedRenderService:
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
# #1749 末帧冻结:素材短于目标段长时克隆末帧铺满(禁慢放)
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
if freeze_seconds > 0:
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
filters.append("format=yuv420p")
# 字幕叠加
@@ -1450,9 +1401,11 @@ class UnifiedRenderService:
"-c:v",
"libx264",
"-crf",
"23",
FFMPEG_ENCODE_CRF,
"-preset",
"medium",
FFMPEG_ENCODE_PRESET,
"-threads",
FFMPEG_ENCODE_THREADS,
"-pix_fmt",
"yuv420p",
"-movflags",
@@ -1515,6 +1468,10 @@ class UnifiedRenderService:
if abs(clip_volume - 1.0) >= 1e-6:
af_parts.append(f"volume={clip_volume:.4f}")
# #1749 音频冻结:视频 tpad 延长后音频需等长补静音,否则时间轴错位
if freeze_seconds > 0:
af_parts.append(f"apad=whole_dur={final_duration:.3f}")
if af_parts:
command.extend(["-af", ",".join(af_parts)])
@@ -1624,7 +1581,9 @@ class UnifiedRenderService:
continue
# 单段裁剪(或无裁剪)
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
# #1749 定稿:目标段长(clip.duration,配音分配结果)始终为准。
# trim 只决定素材内截取区间;素材短于段长 → 末帧冻结(tpad)/音频补静音(apad)
# 铺满,禁止慢放、禁止截断配音。
trim_config = extract_trim_from_clip_config(clip_config)
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
# 用旧字段构造
@@ -1633,42 +1592,48 @@ class UnifiedRenderService:
duration=clip.duration,
)
# 钳制到实际素材时长
effective_trim: TrimConfig | None = None
# 目标段长:配音分配后的 clip.duration 始终为准(不被素材时长钳制)
target_duration = float(clip.duration or 0.0)
final_start = clip.start_time
final_duration = clip.duration
final_duration = target_duration
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
freeze_seconds = 0.0
if trim_config is not None and actual_duration > 0:
effective_trim = trim_config.validate_and_resolve(actual_duration)
if effective_trim.is_valid:
final_start = effective_trim.start_time
final_duration = effective_trim.duration
resolved_trim = trim_config.validate_and_resolve(actual_duration)
if resolved_trim.is_valid:
# trim 仅决定素材内截取区间 [start, min(start+dur, actual)]
final_start = resolved_trim.start_time
avail_in_asset = max(0.0, actual_duration - final_start)
effective_trim = TrimConfig(
start_time=final_start, duration=min(resolved_trim.duration, avail_in_asset)
)
else:
# 裁剪无效 → 使用完整素材
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
effective_trim = None
# 裁剪无效 → 从素材头取
logger.warning("裁剪配置无效,使用素材起点: clip_id=%s", clip.id)
final_start = 0.0
final_duration = actual_duration
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
# 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出
if actual_duration > 0 and final_duration > actual_duration + 0.05:
compensated_speed = actual_duration / final_duration
# 保留用户设置的速度(如果已减速则叠加)
final_speed = configured_speed * compensated_speed
# 下限 0.25x
final_speed = max(0.25, round(final_speed, 4))
logger.info(
"[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)",
clip.id,
final_duration,
actual_duration,
final_speed,
configured_speed,
)
avail_in_asset = actual_duration
else:
final_speed = configured_speed
final_start = 0.0
avail_in_asset = actual_duration if actual_duration > 0 else target_duration
# 素材内可用时长不足目标段长 → 末帧冻结(禁慢放)
if target_duration > 0 and avail_in_asset < target_duration - 0.05:
freeze_seconds = round(target_duration - avail_in_asset, 3)
logger.info(
"[freeze] clip=%s target=%.3f avail=%.3f → 末帧冻结 %.3fs(禁慢放)",
clip.id,
target_duration,
avail_in_asset,
freeze_seconds,
)
final_speed = configured_speed
# freeze 标记写入 config,供视频 tpad / 音频 apad 读取
resolved_config = dict(clip_config)
if freeze_seconds > 0:
resolved_config["_freeze_seconds"] = freeze_seconds
rc = ResolvedClip(
clip_id=clip.id,
@@ -1681,7 +1646,7 @@ class UnifiedRenderService:
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=final_speed,
config=clip_config,
config=resolved_config,
actual_duration=actual_duration,
trim_config=effective_trim,
)
@@ -1690,13 +1655,14 @@ class UnifiedRenderService:
# Debug日志:记录每个clip的时长信息
eff_dur = _clip_effective_duration_pure(final_duration, actual_duration)
logger.info(
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f freeze=%.3f",
clip.id,
final_duration,
actual_duration,
eff_dur,
final_speed,
final_start,
freeze_seconds,
)
# 按 order 排序
@@ -1781,6 +1747,8 @@ class UnifiedRenderService:
# Step 1: 预处理每个 clip — trim + scale + setpts
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
preprocessed_labels: list[str] = []
# 视觉扰动(plan 级别,所有 clip 共享同一套扰动参数)
vp = self._get_visual_perturbation()
for i, clip in enumerate(all_clips):
label = f"v{i}"
role = _resolve_layer_role(clip.clip_type, clip.config)
@@ -1790,18 +1758,23 @@ class UnifiedRenderService:
# trim — 裁剪到指定区间,精确到帧
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
trim_start = getattr(clip, "start_time", 0) or 0
# #1749 冻结时素材内截取时长 = 目标段长 − freeze(末帧冻结由 tpad 铺满,与直通路径同口径)
freeze_seconds = float((clip.config or {}).get("_freeze_seconds", 0.0) or 0.0)
trim_dur = max(0.0, effective_duration - freeze_seconds) if freeze_seconds > 0 else effective_duration
if effective_duration > 0:
if trim_dur > 0:
if trim_start > 0:
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
filters.append(f"trim=start={trim_start:.3f}:duration={trim_dur:.3f}")
else:
filters.append(f"trim=duration={effective_duration:.3f}")
filters.append(f"trim=duration={trim_dur:.3f}")
filters.append("setpts=PTS-STARTPTS")
# 调速 — 基于 setpts 改变播放速度
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{speed:.4f}")
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
effective_speed = speed * vp_speed
if abs(effective_speed - 1.0) >= 1e-6:
filters.append(f"setpts=PTS/{effective_speed:.4f}")
# 倒放滤镜(在 trim 之后、scale 之前应用)
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
@@ -1810,6 +1783,10 @@ class UnifiedRenderService:
if reverse_filter:
filters.append(reverse_filter)
# 视觉扰动:hflip(在 scale 之前,翻转原始画面)
if vp:
self._apply_visual_perturbation_pre_scale(filters, vp)
# scale
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
@@ -1824,6 +1801,10 @@ class UnifiedRenderService:
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
if vp:
self._apply_visual_perturbation_post_scale(filters, vp)
# 调色滤镜(每个 clip 独立的 color grade 配置)
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
@@ -1846,6 +1827,9 @@ class UnifiedRenderService:
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
# #1749 末帧冻结:素材短于目标段长时克隆末帧铺满(禁慢放)
if freeze_seconds > 0:
filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}")
filter_str = f"[{i}:v]{','.join(filters)}[{label}]"
filter_parts.append(filter_str)
@@ -2046,9 +2030,11 @@ class UnifiedRenderService:
"-c:v",
"libx264",
"-crf",
"23",
FFMPEG_ENCODE_CRF,
"-preset",
"medium",
FFMPEG_ENCODE_PRESET,
"-threads",
FFMPEG_ENCODE_THREADS,
"-pix_fmt",
"yuv420p",
"-movflags",
@@ -2128,16 +2114,11 @@ class UnifiedRenderService:
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)。
"""计算 clip 的有效时长(#1749:目标段长为准,素材不足走末帧冻结)。
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration
而非 min(duration, actual_duration)
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration
duration>0 直接返回;慢放补偿已删除)
"""
speed = getattr(clip, "playback_speed", 1.0) or 1.0
# 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration
if speed < 1.0 - 1e-6 and clip.duration > 0:
return clip.duration
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
@@ -2241,6 +2222,82 @@ class UnifiedRenderService:
"""
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
def _get_visual_perturbation(self) -> dict:
# 读取当前 plan 的视觉扰动参数(plan.config.visual_perturbation
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
if not perturbation:
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
if perturbation.get("hflip"):
filters.append("hflip")
def _apply_visual_perturbation_post_scale(self, filters: list[str], perturbation: dict) -> None:
# scale+pad 之后的扰动(zoom/brightness),就地修改 filters
zoom = perturbation.get("zoom_ratio", 1.0)
if zoom > 1.0 + 1e-6:
zoomed_w = int(self.output_width * zoom)
zoomed_h = int(self.output_height * zoom)
filters.append(f"scale={zoomed_w}:{zoomed_h}")
filters.append(f"crop={self.output_width}:{self.output_height}")
brightness = perturbation.get("brightness_shift", 0)
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 静音。"""
+12 -2
View File
@@ -1,10 +1,20 @@
from datetime import datetime, timezone
def mark_asset_used_for_generation(asset) -> None:
def mark_asset_used_for_generation(asset, times: int = 1) -> None:
"""标记素材在成片中被使用,累加使用次数。
Args:
asset: Asset 实体(metadata 就地更新)
times: 本次成片实际使用次数(= 最终成片 plan 中引用该素材的片段数)。
按「成片实际渲染的片段」计数而非请求传入的 asset_ids 列表——
请求列表可能含未被 plan 选用的素材(不应计数),同一素材在多片段
复用时应按片段数累加(高频排除/未使用偏好才与真实渲染强度挂钩)。
"""
times = max(1, int(times or 1))
asset.metadata = {
**asset.metadata,
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + 1,
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + times,
"last_used_at": datetime.now(timezone.utc).isoformat(),
"review_status": asset.metadata.get("review_status") or "pending_review",
}
+277 -80
View File
@@ -376,42 +376,137 @@ def _load_task_info(task_id: str) -> dict | None:
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
"cover_url": getattr(gen_task, "cover_url", "") or "",
"title_config": dict(getattr(gen_task, "title_config", {}) or {}),
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
"source_edit_plan_id": getattr(gen_task, "source_edit_plan_id", "") or "",
}
finally:
session.close()
def _upload_and_record(
# ── #1743 批量变体重渲/封面判定(纯函数,便于单测) ──────────────────────
BATCH_RENDER_SIMILARITY_LIMIT = 0.20
"""批次内成片查重相似度阈值:超过则重选独立 plan 重渲一次(20%)。"""
def should_rerender_for_batch_dedup(*, batch_id: str, render_attempt: int, batch_similarity) -> bool:
"""批次内查重后判定是否需要重选 plan 重渲。
条件(全部满足才重渲):批次任务、首版(attempt==0)、查重率已得出、相似度 > 20%
非批次任务 / 已是重渲版 / 查重率缺失 / 相似度达标 → 不重渲。
"""
if not batch_id:
return False
if render_attempt >= 1:
return False
if batch_similarity is None:
return False
return float(batch_similarity) > BATCH_RENDER_SIMILARITY_LIMIT
def pick_batch_cover_index(task_id: str, candidate_count: int) -> int:
"""批次变体封面帧选取:按 task_id md5 稳定哈希分散到候选帧。
同任务重试结果稳定;批次内不同 task_id 哈希后分散,避免 N 个变体都抽 frame_0
导致封面雷同。非批次调用方应直接取 0(主流程按 batch_id 区分)。
"""
if candidate_count <= 1:
return 0
import hashlib
return int(hashlib.md5(task_id.encode()).hexdigest(), 16) % candidate_count
def _count_plan_clip_asset_usage(session, plan_id: str) -> dict[str, int]:
"""统计最终成片 plan 中每个素材被片段引用的次数。
计数口径(#1743):以成片实际渲染的 edit_plan_clips 为准——
同一素材在多个片段复用按片段数累加;未被 plan 选用的素材(即使
出现在请求 asset_ids 中)不计数。
"""
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
rows = session.query(EditPlanClipModel.asset_id).filter(EditPlanClipModel.plan_id == plan_id).all()
counts: dict[str, int] = {}
for (asset_id,) in rows:
if asset_id:
counts[asset_id] = counts.get(asset_id, 0) + 1
return counts
def _record_rendered_asset_usage(
session,
plan_id: str,
task_id: str,
fallback_asset_ids: list[str] | None = None,
) -> int:
"""按最终成片 plan 的实际片段统计素材使用次数并回写 metadata。
plan 无有效片段素材(异常数据)时退回 fallback_asset_ids 每个计 1 次,
保证使用统计不因数据异常完全丢失。单素材回写失败不影响其他素材。
Returns: 实际回写次数的素材数量。
"""
from worker_app.core.asset_usage import mark_asset_used_for_generation
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
used_counts = _count_plan_clip_asset_usage(session, plan_id)
if not used_counts and fallback_asset_ids:
used_counts = {aid: 1 for aid in fallback_asset_ids if aid}
if not used_counts:
logger.info("[task_id=%s] 素材使用计数: plan=%s 无有效片段素材,跳过", task_id, plan_id)
return 0
asset_repo = SQLAlchemyAssetRepository(session)
written = 0
for aid, times in used_counts.items():
try:
asset = asset_repo.get(aid)
if asset:
mark_asset_used_for_generation(asset, times=times)
asset_repo.update(asset)
written += 1
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s times=%d",
task_id,
aid,
times,
exc_info=True,
)
logger.info(
"[task_id=%s] 素材使用计数回写完成(plan=%s): %d 个素材, 片段引用 %d",
task_id,
plan_id,
written,
sum(used_counts.values()),
)
return written
def _upload_rendered_video(
task_id: str,
output_path: Path,
project_id: str,
batch_id: str,
editing_mode,
user_id: str = "",
video_name: str = "",
thumbnail_url: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
*,
attempt: int = 0,
) -> tuple[str, str]:
"""上传成片到 OSS(不落库)。attempt>0 时文件名带轮次后缀,避免覆盖首版。
Returns:
(file_url, duration, file_size, video_count)
Returns: (file_url, storage_key)
"""
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
suffix = f"_v{attempt}" if attempt > 0 else ""
stem = output_path.stem
name = f"{stem}{suffix}{output_path.suffix or '.mp4'}"
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, name) if p]
storage_key = "/".join(path_parts)
file_size = output_path.stat().st_size
# 上传 OSS
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
upload_start = time.monotonic()
logger.info("[task_id=%s] [OSS上传] 开始上传(attempt=%d): size=%d", task_id, attempt, output_path.stat().st_size)
file_url = upload_to_oss(output_path, storage_key)
upload_elapsed = time.monotonic() - upload_start
if not file_url:
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
if not _verify_url_accessible(verify_url):
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
@@ -420,25 +515,64 @@ def _upload_and_record(
key = normalize_storage_key(file_url)
if not (bucket and bucket.object_exists(key)):
raise RuntimeError(
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, storage_key={storage_key}"
)
logger.info(
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
key,
)
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
return file_url, storage_key
logger.info(
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
task_id,
upload_elapsed,
file_url,
)
# 创建 GeneratedVideo 记录 + 查重
duration = probe_duration(output_path)
def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict) -> str | None:
"""批次内查重超阈值后,为当前任务重新独立选片生成新 plan(#1743 自动重渲)。
复用 API 侧同一套 EditPlanService.reselect_plan_for_variantpackages 层
variant_plan_selector 纯核心),素材池来自任务 asset_ids + 源 plan 素材。
成功返回新 plan_id;失败返回 None(调用方放弃重渲,保留首版)。
"""
try:
from app.services.edit_plan_service import EditPlanService
db = SessionLocal()
try:
svc = EditPlanService(db)
asset_pool = list(task_info.get("task_asset_ids") or [])
_voice_dur = 0.0
try:
_voice_dur = float(task_info.get("voice_duration", 0.0) or 0.0)
except (TypeError, ValueError):
_voice_dur = 0.0
new_plan = svc.reselect_plan_for_variant(
plan_id,
asset_pool,
created_by_user_id=task_info.get("user_id", ""),
name_suffix="重渲变体",
voice_duration=_voice_dur,
)
return new_plan.id
finally:
db.close()
except Exception:
logger.warning("[task_id=%s] 批次重渲前重选 plan 失败,放弃重渲", task_id, exc_info=True)
return None
def _record_video_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
editing_mode,
user_id: str,
file_url: str,
file_size: int,
video_path: str,
video_name: str = "",
thumbnail_url: str = "",
) -> dict:
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
duration = probe_duration(Path(video_path))
dedup_session = SessionLocal()
try:
video_count = create_video_record_and_dedup(
result = create_video_record_and_dedup(
generation_task_id=task_id,
project_id=project_id,
user_id=user_id,
@@ -446,7 +580,7 @@ def _upload_and_record(
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
video_path=video_path,
mode=editing_mode.value,
session=dedup_session,
name=video_name,
@@ -454,11 +588,12 @@ def _upload_and_record(
)
finally:
dedup_session.close()
return file_url, duration, file_size, video_count or 1
result["duration"] = duration
return result
# ── Celery Task ──────────────────────────────────────────────────────────────
# ── Celery Task ──────────────────────────────────────────────────────────────
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
@@ -518,8 +653,8 @@ def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) ->
# 配音下载
voiceover_path: str | None = None
voice_library_id = task_info.get("voice_library_id", "")
voice_ids = task_info.get("voice_ids", []) or []
effective_voice_id = voice_library_id or (voice_ids[0] if voice_ids else "")
# #1749voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定)
effective_voice_id = voice_library_id or ""
if effective_voice_id:
import tempfile
@@ -729,19 +864,29 @@ def generate_video(self, task_id: str) -> dict:
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
_flush_logs(task_id, gen_task)
output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir, thumbnail_url = (
_render_from_edit_plan(
# ── 渲染→上传→查重→(批次超阈值则重选 plan 重渲一次)循环(#1743)──
current_plan_id = source_edit_plan_id
file_url = ""
duration = 0.0
file_size = 0
video_count = 1
file_size_final = 0
for render_attempt in range(2): # 首版 + 最多 1 次重渲
(
output_path,
render_duration,
cover_candidates,
voiceover_tmp_path,
render_temp_dir,
thumbnail_url,
) = _render_from_edit_plan(
task_id=task_id,
source_edit_plan_id=source_edit_plan_id,
source_edit_plan_id=current_plan_id,
task_info=task_info,
)
)
# 从这里开始,render_temp_dir 已赋值,必须确保异常时也能清理
try:
if gen_task:
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
gen_task.append_log("渲染", f"渲染完成(第{render_attempt + 1}版), 时长={render_duration:.1f}s")
_flush_logs(task_id, gen_task)
_update_task_progress(task_id, 80, "渲染完成")
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
@@ -751,7 +896,7 @@ def generate_video(self, task_id: str) -> dict:
cropped_path = random_edge_crop(output_path)
if cropped_path != output_path:
output_path = cropped_path
if gen_task:
if gen_task and render_attempt == 0:
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
_flush_logs(task_id, gen_task)
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
@@ -762,45 +907,110 @@ def generate_video(self, task_id: str) -> dict:
crop_err,
exc_info=True,
)
if gen_task:
gen_task.append_log("边缘裁剪", f"裁剪失败,使用原始视频: {crop_err}")
_flush_logs(task_id, gen_task)
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────
# ── 4. 上传 OSS(不落库) ───────────────────────────────
_update_task_progress(task_id, 85, "开始上传")
file_url, duration, file_size, video_count = _upload_and_record(
file_url, _storage_key = _upload_rendered_video(
task_id=task_id,
output_path=output_path,
project_id=project_id,
attempt=render_attempt,
)
file_size = output_path.stat().st_size
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
dedup_info = _record_video_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
editing_mode=editing_mode,
user_id=user_id,
file_url=file_url,
file_size=file_size,
video_path=str(output_path),
video_name=task_info.get("video_title", ""),
thumbnail_url=thumbnail_url,
)
duration = dedup_info.get("duration", render_duration)
video_count = dedup_info.get("video_count", 1)
batch_sim = dedup_info.get("batch_similarity")
if gen_task:
gen_task.append_log(
"OSS上传",
f"上传成功, 大小={file_size}",
f"{render_attempt + 1}上传成功, 大小={file_size}"
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
file_size=file_size,
file_url=file_url,
)
_flush_logs(task_id, gen_task)
_update_task_progress(task_id, 95, "上传完成")
finally:
# 清理渲染临时目录(无论后续步骤成功与否都清理)
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
if not should_rerender_for_batch_dedup(
batch_id=batch_id,
render_attempt=render_attempt,
batch_similarity=batch_sim,
):
file_size_final = file_size
break
# 批次内相似度过高:重选独立 plan 后重渲一次
logger.warning(
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
task_id,
batch_sim,
BATCH_RENDER_SIMILARITY_LIMIT,
)
if gen_task:
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
_flush_logs(task_id, gen_task)
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
if not new_plan_id:
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
file_size_final = file_size
break
# 回写任务关联的 plan(重渲版以新 plan 渲染)
try:
_ps = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_pr = SQLAlchemyGenerationTaskRepository(_ps)
_gt = _pr.get(task_id)
if _gt:
_gt.source_edit_plan_id = new_plan_id
_pr.update(_gt)
finally:
_ps.close()
except Exception:
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
current_plan_id = new_plan_id
# 清理本轮临时目录,下一轮重新渲染
if render_temp_dir:
import shutil
shutil.rmtree(render_temp_dir, ignore_errors=True)
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
render_temp_dir = None
file_size = file_size_final or file_size
_update_task_progress(task_id, 95, "上传完成")
# 渲染结束后清理临时目录(重渲循环内每轮已清理,此处兜底最后一轮)
if render_temp_dir:
import shutil
shutil.rmtree(render_temp_dir, ignore_errors=True)
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
try:
if cover_candidates:
first = cover_candidates[0]
# #1743:批量变体封面差异化——候选帧按 task_id 稳定哈希分散选取
# (同任务重试稳定,批次内不同任务落在不同帧位),非批次取首帧。
_cover_idx = pick_batch_cover_index(task_id, len(cover_candidates)) if batch_id else 0
first = cover_candidates[_cover_idx]
cover_frame_url = first.get("image_url") or first.get("url") or ""
if cover_frame_url:
_cover_session = SessionLocal()
@@ -859,35 +1069,22 @@ def generate_video(self, task_id: str) -> dict:
logger.warning("[task_id=%s] 更新标题使用次数异常", task_id, exc_info=True)
# 5.2 更新素材使用次数
# 按「最终成片 plan 实际渲染的片段」计数(#1743):不用请求传入的
# task.asset_ids(可能含未被 plan 选用的素材),同一素材多片段复用
# 按片段数累加,使 unused_bonus / 高频排除与真实渲染强度挂钩。
# current_plan_id 是重渲循环结束后最终成片所用 plan(首版或重渲版)。
try:
from worker_app.core.asset_usage import mark_asset_used_for_generation
_asset_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
_gen_task = _task_repo.get(task_id)
if _gen_task and _gen_task.asset_ids:
for _aid in _gen_task.asset_ids:
try:
_asset = _asset_repo.get(_aid)
if _asset:
mark_asset_used_for_generation(_asset)
_asset_repo.update(_asset)
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
task_id,
_aid,
exc_info=True,
)
_fallback_ids: list[str] = []
_gt_for_assets = SQLAlchemyGenerationTaskRepository(_asset_session).get(task_id)
if _gt_for_assets:
_fallback_ids = list(_gt_for_assets.asset_ids or [])
_record_rendered_asset_usage(_asset_session, current_plan_id, task_id, _fallback_ids)
finally:
_asset_session.close()
except Exception:
+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:
@@ -34,7 +34,7 @@ class WechatSyncRequest:
):
self.openid = openid.strip()
self.unionid = unionid.strip() if unionid else ""
self.nickname = nickname or "微信用户"
self.nickname = nickname or "小虾同学" # 微信新规拿不到真实昵称,新用户默认昵称
self.avatar_url = avatar_url or ""
self.source = source
@@ -227,7 +227,7 @@ class WechatSyncUseCase:
id=user_id,
email=email,
username=username,
display_name=request.nickname or "微信用户",
display_name=request.nickname or "小虾同学",
password_hash=password_hash,
email_verified=True, # 微信登录视为已验证
wechat_openid=request.openid,
+1 -2
View File
@@ -16,7 +16,6 @@ class CreateGenerationTaskCommand:
template_id: str = ""
asset_ids: list[str] = field(default_factory=list)
title_ids: list[str] = field(default_factory=list)
voice_ids: list[str] = field(default_factory=list)
created_by_user_id: str = ""
source_edit_plan_id: str = ""
asset_select_mode: str = ""
@@ -48,7 +47,7 @@ class CreateGenerationTaskUseCase:
template_id=command.template_id,
asset_ids=command.asset_ids,
title_ids=command.title_ids,
voice_ids=command.voice_ids,
voice_ids=[], # #1749voice_ids 已废弃(冗余 voice_library_id),DB 列保留只读
status="pending", # type: ignore[arg-type]
progress=0.0,
result_count=0,
@@ -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,
)

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