Compare commits

..

20 Commits

Author SHA1 Message Date
frontend-dev ac5353b335 fix(e2e): 适配 #1970 五步向导重构 + 修复 upload 转码超时 (#2048) (#2049)
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 Worker Image (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 (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 / 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 10s
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 Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m12s
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 1m26s
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
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 / 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 / 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 Web Image (push) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m20s
CI/CD Pipeline / Validate - Style (push) Successful in 4m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m16s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m3s
CI/CD Pipeline / Retag skipped Staging Web Image (push) 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 / Frontend Unit Tests (push) Successful in 6m20s
CI/CD Pipeline / Validate - Security (push) Successful in 6m28s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Unit Tests (push) Successful in 11m4s
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 28m1s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-26 11:20:47 +08:00
xiaoxia 44a98b8fcb chore(cover): 清理PreviewCountModal残留+修复批量finalize封面标题错配 (#2048)
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 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 / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
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 / Build Staging Worker Image (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 / PR Build Web Image (pull_request) Successful in 53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m28s
CI/CD Pipeline / Validate - Style (push) Successful in 3m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m28s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m8s
CI/CD Pipeline / Validate - Security (push) Successful in 5m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m57s
CI/CD Pipeline / Integration Tests (push) Successful in 8m48s
CI/CD Pipeline / Unit Tests (push) Successful in 13m55s
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 / Build Staging Worker Image (push) Successful in 22m30s
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 1m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 35m14s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-26 09:46:35 +08:00
xiaoxia 755b3a8eb4 fix(cover): 修复9个封面/流程UX问题 (#2047)
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 / 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 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 - 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 / Check push changed paths (push) Successful in 11s
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 46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
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 / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m44s
CI/CD Pipeline / Validate - Style (push) Successful in 3m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m0s
CI/CD Pipeline / Integration Tests (push) Successful in 5m31s
CI/CD Pipeline / Validate - Security (push) Successful in 5m36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m29s
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 / Frontend Unit Tests (push) Successful in 6m37s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m55s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 9m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m57s
CI/CD Pipeline / Unit Tests (push) Successful in 12m41s
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 / 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-26 09:25:52 +08:00
xiaoxia 63e1889bc4 fix(cover): 打通批量封面模板选择的 prop 链路 (#2046)
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 / Check if frontend-only change (pull_request) Successful in 3s
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 (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 API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
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 / 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 58s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 59s
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 / PR Build API Image (pull_request) Successful in 1m4s
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 / 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 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Build Staging API Image (push) Successful in 55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
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 1m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m36s
CI/CD Pipeline / Validate - Style (push) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m7s
AI Code Review / AI Code Review (pull_request) Successful in 6m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m10s
CI/CD Pipeline / Unit Tests (push) Successful in 13m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-26 00:03:19 +08:00
xiaoxia c7dffb858b fix(cover): 批量生成场景支持封面模板选择与应用 (#2045)
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 / Check if frontend-only change (pull_request) Successful in 3s
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 (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 / Check push changed paths (push) Successful in 11s
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 / Frontend 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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m31s
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
PR Automation / Auto Merge on CI Green + Approved (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 / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m0s
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 52s
AI Code Review / AI Code Review (pull_request) Successful in 6m30s
CI/CD Pipeline / Validate - Style (push) Successful in 6m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m14s
CI/CD Pipeline / Validate - Security (push) Successful in 10m54s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 11m16s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 13m5s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(cover): 批量生成场景补封面模板选择;批量生成应用所选模板

- Step6CoverSettings 批量分支补「封面模板」「新建模板」按钮
- 批量分支渲染 CoverSettingsModal/CoverEditorModal
- useBatchCovers 移除 _selectedTemplate 下划线,实际使用 selectedTemplate
- generateCover 传 selectedTemplate 而非硬编码 default
- 批量上传改走素材库(uploadOne),不再用临时 blob URL
2026-09-25 23:45:19 +08:00
xiaoxia 407516e78b fix(cover): 系统模板套用走新建路径 + 403/401错误提示增强 (#2044)
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 / 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 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Frontend Lint (push) 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 / Frontend Unit Tests (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 14s
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 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m39s
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 / PR Build API Image (pull_request) Successful in 3m36s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m6s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
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 4m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m42s
CI/CD Pipeline / Validate - Style (push) Successful in 5m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m1s
AI Code Review / AI Code Review (pull_request) Successful in 7m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 9m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 11m50s
CI/CD Pipeline / Validate - Security (push) Successful in 12m1s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-25 22:47:19 +08:00
xiaoxia 76928d2dfc fix(cover): 修复AI数字人封面模板403 (#2043)
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 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m5s
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 Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m50s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (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 3m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
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 4m35s
CI/CD Pipeline / Integration Tests (push) Successful in 4m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / Validate - Style (push) Successful in 5m14s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m47s
AI Code Review / AI Code Review (pull_request) Successful in 6m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m52s
CI/CD Pipeline / Validate - Security (push) Successful in 10m40s
CI/CD Pipeline / Unit Tests (push) Successful in 12m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-25 22:10:31 +08:00
xiaoxia d6e63349cb fix(cover): 修复AI数字人封面打开崩溃(.text undefined) (#2037)
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 / Check if frontend-only change (pull_request) 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 / 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 / Check push changed paths (push) Successful in 12s
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 - Python (mypy + alembic) (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 / 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 / PR Build Web Image (pull_request) Successful in 1m3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
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 / Build Staging Web Image (push) Successful in 1m51s
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 / 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 API Image (pull_request) Successful in 3m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m8s
CI/CD Pipeline / Validate - Style (push) Successful in 3m37s
CI/CD Pipeline / Integration Tests (push) Successful in 4m40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m14s
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 / Frontend Unit Tests (push) Successful in 5m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 41s
CI/CD Pipeline / Validate - Security (push) Successful in 6m25s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m24s
CI/CD Pipeline / Unit Tests (push) Successful in 15m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-25 18:14:55 +08:00
xiaoxia 751f8ad84e feat(#2035): 语义标签增强 + 质量评分自动计算 + atom_clip caption/embedding (#2036)
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 / 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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
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 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 / PR Build API Image (pull_request) Successful in 1m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m22s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m13s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m11s
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 2m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Validate - Security (push) Successful in 6m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
AI Code Review / AI Code Review (pull_request) Successful in 6m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m38s
CI/CD Pipeline / Unit Tests (push) Successful in 10m3s
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 / 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-25 12:44:37 +08:00
xiaoxia 1f3b04cde5 refactor(cover): AI数字人封面复用智能剪辑共享组件 (#2035)
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 / Check if frontend-only change (pull_request) Successful in 3s
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 3s
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 / Frontend Lint (push) 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 7s
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 / Unit Tests (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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
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 / 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 / PR Build API Image (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m6s
CI/CD Pipeline / Validate - Style (push) Successful in 3m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m4s
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 / Frontend Unit Tests (push) Successful in 4m53s
CI/CD Pipeline / Validate - Security (push) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 10m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 10m12s
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
CI/CD Pipeline / Unit Tests (push) Successful in 10m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (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 / Build Staging Worker Image (push) Successful in 16m35s
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 43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-25 10:45:47 +08:00
xiaoxia 1be658f72d #2034 fix: dedup_enabled 关开关时彻底关闭视觉/像素扰动 (#2034)
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 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 / 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 11s
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 / PR Build Worker Image (pull_request) Successful in 59s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m14s
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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m56s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m55s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m59s
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 API 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 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m13s
CI/CD Pipeline / Validate - Security (push) Successful in 5m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 12m20s
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
#2034 fix: dedup_enabled 开关同时控制批量变体的 visual/pixel 扰动

- unified_render_service._get_visual_perturbation() 开头加 dedup_enabled 守门
- edit_plan_service._build_variant_config_update 读取源 plan.dedup_enabled,关开关时不注入 visual_perturbation 和 pixel_perturbation
- 保留节奏模板洗牌和 BGM 池差异化(合理多变体差异,不属于降重扰动)
- 同步修复提交时尾部误写入的 SHA 行(语法修复)
2026-09-25 09:50:44 +08:00
xiaoxia 9039fcaea9 fix(api): 加 editor/drafts、editor/clips/from-assets、api/health 路径别名,修复前端 404 导致的 10s 超时 (#2033)
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
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 / Frontend Lint (push) 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 10s
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 / 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 / PR Build API Image (pull_request) Successful in 57s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (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 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
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 1m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m54s
CI/CD Pipeline / Integration Tests (push) Successful in 5m21s
CI/CD Pipeline / Validate - Style (push) Successful in 5m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m56s
AI Code Review / AI Code Review (pull_request) Successful in 7m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m4s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 11m47s
CI/CD Pipeline / Validate - Security (push) Successful in 13m22s
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-25 01:27:20 +08:00
xiaoxia 54368c24ff fix(cover): 修复封面编辑器多项bug;加长草稿保存/全局timeout (#2032)
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 / 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 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) 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 / 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 (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 32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m49s
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 Approve on CI Green (pull_request) Successful in 3m22s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m15s
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 / Build Staging Worker Image (push) Successful in 4m50s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
CI/CD Pipeline / Integration Tests (push) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
PR Automation / Auto Merge on CI Green + Approved (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 / 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 7m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m44s
CI/CD Pipeline / Build Staging API Image (push) Failing after 11m19s
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 13m51s
CI/CD Pipeline / Validate - Security (push) Successful in 13m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m14s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (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-25 01:02:14 +08:00
xiaoxia a998ccd527 fix(nginx): production 补 index.html no-cache 精确块,防旧缓存导致动态 import 失败 (#2031)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Validate - Security (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 (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 / 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 / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m0s
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 / Production Browser E2E (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 / Build Staging API Image (push) Successful in 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m41s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m33s
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 2m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
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 / Frontend Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m4s
CI/CD Pipeline / Validate - Style (push) Successful in 5m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 6m11s
CI/CD Pipeline / Validate - Security (push) Successful in 9m39s
CI/CD Pipeline / Unit Tests (push) Successful in 10m14s
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-24 18:47:53 +08:00
xiaoxia dd16f8f783 fix(generation): #2028 awaiting_cover 预览 + from-assets 容错 (#2029)
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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (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 / Check if frontend-only change (pull_request) Successful in 4s
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 / Check push changed paths (push) Successful in 11s
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 / 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 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 57s
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 / 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 54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 35s
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 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
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 / Frontend Unit Tests (push) Successful in 3m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m0s
CI/CD Pipeline / Validate - Style (push) Successful in 4m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
CI/CD Pipeline / Integration Tests (push) Successful in 6m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 9m22s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 10m54s
CI/CD Pipeline / Validate - Security (push) Successful in 11m32s
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 / Build Production API Image (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-24 18:02:30 +08:00
xiaoxia 1528d6b59c fix(generate): Step5 完成按钮补调 finalize 接口,视频才真正入成品库 (#2030)
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 / 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 / Check push changed paths (push) Successful in 8s
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 / 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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration 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 / PR Build Web Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 28s
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 / 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 / Build Production Web Image (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 / 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 34s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Integration Tests (push) Successful in 5m6s
CI/CD Pipeline / Validate - Style (push) Successful in 5m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m43s
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m15s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 11m46s
CI/CD Pipeline / Validate - Security (push) Successful in 14m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(generate): Step5「完成」按钮补上 finalize 调用(PR #2030)

- 新增 apps/web/src/api/generation/finalize.ts 封装 POST /generation/tasks/{id}/finalize
- handleFinish 改为 confirm → finalize 两步:confirm 同步封面+标题,finalize 真正入库成品库
- 批量场景每个 task 并行 confirm + finalize
- tsc/eslint/prettier 均通过
2026-09-24 17:21:40 +08:00
xiaoxia 445375e1cb feat(ai-avatar): 对口型生成弹窗加实时计时器 + 完成总耗时显示 (#2027)
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 8s
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 4s
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 / 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 Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m18s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 39s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 38s
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 / 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 / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Validate - Style (push) Successful in 4m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Validate - Security (push) Successful in 9m4s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 9m24s
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
AI Code Review / AI Code Review (pull_request) Successful in 9m53s
CI/CD Pipeline / Unit Tests (push) Successful in 10m56s
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 / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m36s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (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-24 12:40:27 +08:00
xiaoxia 23fe5f9822 feat(generation): #2024 视频渲染后延迟到封面确认才入库(新增 AWAITING_COVER 状态 + finalize 接口) (#2026)
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 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (push) 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 56s
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 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 2m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
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 / Build Staging API 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 / 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 - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
AI Code Review / AI Code Review (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-24 12:34:43 +08:00
xiaoxia c32065207a feat(generate): 封面编辑器完整实现 — 7个面板全部控件 + 右侧画布实时预览 (#2025)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 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 / 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 12s
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 API Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
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 1m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 54s
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 Web Image (push) Successful in 40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m25s
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 / Integration Tests (push) Successful in 4m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Validate - Style (push) Successful in 5m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m48s
CI/CD Pipeline / Validate - Security (push) Successful in 12m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m9s
CI/CD Pipeline / Unit Tests (push) Successful in 13m46s
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
feat: 封面编辑器完整实现
2026-09-24 11:28:34 +08:00
xiaoxia c9a8691b77 refactor(generate): Step4删除生成配置卡片;Step5默认封面模板+完成按钮保存入库 (#2024)
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 / Frontend Lint (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 (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m26s
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
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 / 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 / Build Staging API Image (push) Successful in 3m36s
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 2m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m42s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Style (push) Successful in 5m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m45s
CI/CD Pipeline / Integration Tests (push) Successful in 6m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 11m32s
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 12m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m0s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 16m25s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (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
refactor(generate): Step4删除生成配置卡片;Step5默认封面模板+完成按钮保存入库 (#2024)
2026-09-24 11:11:30 +08:00
73 changed files with 6979 additions and 983 deletions
@@ -0,0 +1,33 @@
"""asset_atom_clips 新增 caption/embedding 字段(#2035 语义标签增强)
Revision ID: 085_atom_clip_caption_embedding
Revises: 084_lipsync_jobs_style
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "085_atom_clip_caption_embedding"
down_revision = "084_lipsync_jobs_style"
branch_labels = None
depends_on = None
def upgrade() -> None:
# caption: 中文画面描述(10-30字)
op.add_column(
"asset_atom_clips",
sa.Column("caption", sa.Text(), nullable=True),
)
# embedding: caption 对应的向量(豆包 embedding 接口返回,JSON 存 float 数组)
op.add_column(
"asset_atom_clips",
sa.Column("embedding", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "embedding")
op.drop_column("asset_atom_clips", "caption")
+19 -1
View File
@@ -8,6 +8,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.clips_standalone import router as clips_standalone_router
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.drafts_standalone import router as drafts_standalone_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_cover import router as generation_cover_router
@@ -19,7 +20,8 @@ from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.points import points_router, usage_router
from app.api.routes.points import router as points_router
from app.api.routes.points import usage_router
from app.api.routes.projects import router as projects_router
from app.api.routes.scripts import router as scripts_router
from app.api.routes.scripts_ai import router as scripts_ai_router
@@ -41,6 +43,19 @@ api_router = APIRouter(prefix="/api/v1")
health_router = APIRouter()
health_router.include_router(health_check_router)
# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ──────────────
# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。
api_health_router = APIRouter(prefix="/api")
api_health_router.include_router(health_check_router)
health_router.include_router(api_health_router)
# ── 旧前端路径别名(无需 template_id 路径参数)────────────────────────────
# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*,
# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。
editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"])
editor_legacy_router.include_router(clips_standalone_router)
editor_legacy_router.include_router(drafts_standalone_router)
api_router.include_router(
auth_router,
tags=["Auth"],
@@ -169,6 +184,9 @@ api_router.include_router(
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
editor_legacy_router,
)
api_router.include_router(
tts_router,
prefix="/tts",
@@ -0,0 +1,102 @@
"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径).
提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致:
- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底)
- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用)
根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id),
与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。
本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ._default_template import get_or_create_default_template_id
from .templates_editor.dependencies import resolve_draft_plan_id
from .templates_editor.draft import get_editor_draft, update_editor_draft
from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Editor Legacy Alias"])
def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]:
return EditTemplateService(db), EditPlanService(db)
def _resolve_template_id(
template_id: str | None,
db: Session,
current_user: AuthenticatedUser,
) -> str:
"""解析 template_id:query/body 优先,否则兜底默认模板。"""
tid = (template_id or "").strip()
if tid:
return tid
user_id = str(current_user.user.id)
tid = get_or_create_default_template_id(db, user_id)
if not tid:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="无法自动创建默认模板,请刷新页面重试",
)
return tid
@router.get("/drafts", response_model=EditorDraftResponse)
def get_editor_drafts_alias(
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditorDraftResponse:
"""获取草稿详情(复数路径别名,兼容旧前端调用)。"""
tid = _resolve_template_id(template_id, db, current_user)
services = _resolve_editor_services(db)
plan_id = resolve_draft_plan_id(
template_id=tid,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
return get_editor_draft(
template_id=tid,
plan_id=plan_id,
services=services,
_=current_user,
)
@router.put("/drafts", response_model=EditorDraftResponse)
def update_editor_drafts_alias(
req: EditorUpdateRequest,
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditorDraftResponse:
"""更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。"""
tid = _resolve_template_id(template_id, db, current_user)
services = _resolve_editor_services(db)
plan_id = resolve_draft_plan_id(
template_id=tid,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
return update_editor_draft(
template_id=tid,
req=req,
plan_id=plan_id,
services=services,
_=current_user,
)
+19 -5
View File
@@ -76,10 +76,7 @@ class GenerateCoverResponse(BaseModel):
# ── Route ────────────────────────────────────────────────────────────────
def _select_best_frame_from_snapshots(
snapshots: list[dict], plan_id: str
) -> str:
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
降级策略:cv2 不可用或评分失败时,返回第一帧。
@@ -232,7 +229,10 @@ def _persist_cover_frame(
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
"""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
@@ -241,6 +241,20 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
return getattr(videos[0], "file_url", "") or ""
except Exception:
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
try:
task_repo = SQLAlchemyGenerationTaskRepository(db)
task = task_repo.get(task_id)
if task is not None:
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
if _status == "awaiting_cover":
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
_url = _ro.get("file_url") or ""
if _url:
return _url
except Exception:
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
return None
+16 -1
View File
@@ -650,11 +650,26 @@ def get_preview_generation_task(
if not getattr(task, "is_preview", False):
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
# 查询生成的视频(取第一个)
# 查询生成的视频(取第一个)。
# #2024: 渲染完成后先进入 awaiting_cover(未入成品库),此时预览也应可见,
# 从 extra_meta["rendered_output"] 读取视频 URL。
generated_videos = []
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val == "completed":
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
generated_videos = list_use_case.execute(task_id)
elif status_val == "awaiting_cover":
# 用 extra_meta 中的渲染信息组装一个轻量视频对象给前端预览播放
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
if _ro.get("file_url"):
class _PreviewVideo:
def __init__(self, ro):
self.file_url = ro.get("file_url", "")
self.duration = float(ro.get("duration") or 0.0)
self.file_size = int(ro.get("file_size") or 0)
generated_videos = [_PreviewVideo(_ro)]
return _to_preview_response(task, generated_videos=generated_videos)
+206 -5
View File
@@ -31,6 +31,8 @@ from app.schemas.generation_task import (
BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest,
FinalizeGenerationRequest,
FinalizeGenerationResponse,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
@@ -44,6 +46,37 @@ from packages.application import (
ListGeneratedVideosByTaskUseCase,
)
from packages.domain.smart_match import smart_select_assets
# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度)
# AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other
_CATEGORY_KEYWORDS: dict[str, set[str]] = {
"scenic": {"风景", "自然", "山水", "大海", "天空", "日落", "日出", "森林", "城市", "建筑", "夜景", "街道", "公园", "景区", "旅行", "旅游", "户外"},
"product": {"产品", "商品", "展示", "演示", "开箱", "评测", "好物", "推荐", "种草", "购物", "电商", "带货", "品牌", "广告", "包装"},
"person": {"人物", "人物采访", "对话", "说话", "讲解", "演讲", "采访", "聊天", "开会", "工作", "办公室", "团队", "员工", "老板", "女性", "男性", "美女", "帅哥"},
"animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"},
"food": {"美食", "食物", "餐饮", "餐厅", "做饭", "烹饪", "厨房", "菜品", "饮料", "水果", "甜点", "蛋糕", "咖啡", "茶", "零食", "吃"},
"tech": {"科技", "数码", "电脑", "手机", "屏幕", "软件", "APP", "互联网", "AI", "人工智能", "机器人", "办公", "程序员", "代码", "屏幕录制"},
"sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"},
"music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"},
}
def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
"""从文案标签集合推断期望的素材分类(可能命中多个)。标签为空返回 None。"""
if not script_tags:
return None
matched: set[str] = set()
for cat, kws in _CATEGORY_KEYWORDS.items():
for tag in script_tags:
tag.lower()
for kw in kws:
if kw in tag or tag in kw:
matched.add(cat)
break
if cat in matched:
break
return matched or None
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -132,10 +165,11 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
def _select_assets_from_library(
assets: list,
mode: str,
count: int,
count: int = 0,
rng=None,
script_tags: list | None = None,
tag_names_by_id: dict | None = None,
db=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -156,6 +190,42 @@ def _select_assets_from_library(
if not ready_video_assets:
return []
# #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。
# 失败降级为空(不影响选片主流程)。
clip_ai_tags_by_asset: dict[str, list[dict]] = {}
ai_tags_by_asset: dict[str, dict] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重)
try:
if db is not None:
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
ready_ids = [a.id for a in ready_video_assets]
clip_rows = (
db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags)
.filter(AssetAtomClipModel.asset_id.in_(ready_ids))
.filter(AssetAtomClipModel.ai_tags.isnot(None))
.all()
)
agg: dict[str, dict] = {}
for asset_id, ai_tags in clip_rows:
if not isinstance(ai_tags, dict):
continue
clip_ai_tags_by_asset.setdefault(asset_id, []).append(ai_tags)
# 聚合:合并 scene/objects/action 去重
agg.setdefault(asset_id, {"scene": [], "objects": [], "action": [], "shot": "", "has_text": False})
for key in ("scene", "objects", "action"):
for v in ai_tags.get(key) or []:
v = str(v).strip()
if v and v not in agg[asset_id][key]:
agg[asset_id][key].append(v)
if ai_tags.get("has_text") is True:
agg[asset_id]["has_text"] = True
if not agg[asset_id]["shot"] and ai_tags.get("shot"):
agg[asset_id]["shot"] = ai_tags["shot"]
ai_tags_by_asset = agg
except Exception: # noqa: BLE001
logger.warning("[选片] 加载片段 AI 标签失败,降级不使用语义匹配", exc_info=True)
clip_ai_tags_by_asset = {}
ai_tags_by_asset = {}
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
if script_tags:
from packages.domain.narrative_match import pick_narrative_assets
@@ -165,6 +235,7 @@ def _select_assets_from_library(
ready_video_assets,
script_tags=script_tags,
tag_names_by_id=tag_names_by_id,
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
limit=limit,
rng=rng,
)
@@ -175,7 +246,18 @@ def _select_assets_from_library(
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
limit = count if count > 0 else None
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
# #2035:给 smart_select_assets 传入文案标签和 AI 标签映射,启用语义维度
norm_script = {t.strip().lower() for t in (script_tags or []) if t and t.strip()}
expected_categories = _infer_expected_categories(norm_script)
results = smart_select_assets(
ready_video_assets,
limit=limit,
kind="video",
rng=rng,
script_tags=norm_script if norm_script else None,
ai_tags_by_asset=ai_tags_by_asset or None,
expected_categories=expected_categories,
)
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
@@ -394,6 +476,7 @@ def create_generation_task(
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
db=db,
)
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
@@ -408,6 +491,7 @@ def create_generation_task(
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
db=db,
)
if not resolved_asset_ids:
raise HTTPException(
@@ -891,8 +975,14 @@ def confirm_generation(
if source_task.project_id:
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
if source_task.is_completed and getattr(source_task, "is_preview", False):
# 3. 如果预览任务已完成渲染(completed 或 awaiting_cover),检查分辨率一致性后复用产物(秒出)。
# #2024: 渲染完成先进入 awaiting_cover(等 Step5 finalize 入库),
# confirm 时不再直接 finalize——仍创建 is_preview=False 的正式任务,复用预览渲染产物。
_preview_done = getattr(source_task, "is_preview", False) and source_task.status.value in (
"completed",
"awaiting_cover",
)
if _preview_done:
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
req_w = request.output_width or 0
req_h = request.output_height or 0
@@ -907,13 +997,25 @@ def confirm_generation(
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
# #2024: mark_confirmed 会把 is_preview 翻转为 False、同步标题/分辨率/封面,
# 但不再自动 mark_completed——任务停留在 awaiting_cover,等待用户 Step5 选封面后调 finalize。
source_task.mark_confirmed(
cover_url=request.cover_url,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
# 若预览任务此时是 completed(历史数据/旧 worker),回退到 awaiting_cover 统一流程
if source_task.status.value == "completed":
try:
from packages.domain.generation_task import GenerationTaskStatus
source_task.status = GenerationTaskStatus.AWAITING_COVER
source_task.completed_at = None
except Exception:
pass
generation_task_repository.update(source_task)
db.commit()
# 同步标题到 EditPlan.config
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
@@ -926,7 +1028,7 @@ def confirm_generation(
)
logger.info(
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
"[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s",
task_id,
authenticated_user.user.id,
)
@@ -995,6 +1097,67 @@ def confirm_generation(
)
@router.post("/tasks/{task_id}/finalize", response_model=FinalizeGenerationResponse)
def finalize_generation_task(
task_id: str,
request: FinalizeGenerationRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
db: Session = Depends(get_db_session),
) -> FinalizeGenerationResponse:
"""#2024: Step5 点「完成」时调用——将 awaiting_cover 状态的任务正式入库+绑定封面。
- 任务必须处于 awaiting_cover 状态(渲染+上传已完成、封面候选已就绪)。
- cover_url 为空则使用任务自动截帧/智能封面;非空则绑定为最终封面。
- 幂等:已 finalize 的任务直接返回已有视频记录。
- 成功后任务推进到 completed,返回成品视频 ID + 可播放 URL。
"""
from app.services.generation_finalize_service import (
GenerationFinalizeError,
GenerationFinalizeService,
)
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
service = GenerationFinalizeService(db)
try:
video = service.finalize_task(
task_id=task_id,
user_id=authenticated_user.user.id,
cover_url=request.cover_url or None,
custom_title=(request.custom_title or "").strip() or None,
)
except GenerationFinalizeError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
try:
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
except Exception:
download_url = video.file_url
return FinalizeGenerationResponse(
video_id=video.id,
project_id=getattr(video, "project_id", "") or "",
name=getattr(video, "name", "") or "",
file_size=int(getattr(video, "file_size", 0) or 0),
duration=float(getattr(video, "duration", 0.0) or 0.0),
thumbnail_url=video.thumbnail_url or "",
cover_url=video.thumbnail_url or "",
file_url=download_url,
width=int(getattr(video, "width", 0) or 0),
height=int(getattr(video, "height", 0) or 0),
fps=float(getattr(video, "fps", 0.0) or 0.0),
status="success",
is_duplicate=bool(getattr(video, "is_duplicate", False)),
)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -1042,6 +1205,44 @@ def list_generation_results(
for item in items:
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
responses.append(_to_generated_video_response(item, download_url=download_url))
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if not responses and status_val == "awaiting_cover":
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
_file_url = _ro.get("file_url") or ""
if _file_url:
if _file_url.startswith("http"):
_download = _file_url
else:
try:
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
except Exception:
_download = _file_url
_name = _ro.get("name") or ""
if not _name:
_name = f"generated-{task_id[:8]}"
responses.append(
GeneratedVideoResponse(
id=f"preview-{task_id}",
project_id=getattr(task, "project_id", "") or "",
generation_task_id=task_id,
name=_name,
file_url=_file_url,
file_size=int(_ro.get("file_size") or 0),
duration=float(_ro.get("duration") or 0.0),
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
width=int(_ro.get("width") or 0),
height=int(_ro.get("height") or 0),
fps=float(_ro.get("fps") or 0.0),
mode=_ro.get("mode", ""),
download_url=_download,
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
)
)
return ListGeneratedVideosResponse(items=responses)
+9 -3
View File
@@ -63,6 +63,8 @@ def _generation_step(task) -> str:
return "等待 Worker 执行"
if s == "running":
return "正在生成成片"
if s == "awaiting_cover":
return "等待确认封面"
if s == "completed":
return "生成完成"
if s == "failed":
@@ -129,7 +131,7 @@ def _validate_status(status: str | None) -> str | None:
"""校验状态值合法性。"""
if status is None:
return None
valid = {"pending", "running", "completed", "failed", "cancelled"}
valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"}
if status not in valid:
raise HTTPException(
status_code=400,
@@ -151,7 +153,9 @@ def _clamp_page_size(page_size: int) -> int:
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
status: str | None = Query(
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
@@ -248,7 +252,9 @@ def retry_task_by_id(
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
def list_project_tasks(
project_id: str,
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
status: str | None = Query(
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
@@ -682,17 +682,50 @@ def create_clips_from_assets_editor(
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
asset_scene_points: dict[str, list[float]] = {}
invalid_asset_ids: list[str] = []
valid_asset_ids: list[str] = []
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
if asset is None:
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
invalid_asset_ids.append(asset_id)
continue
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
if _dur <= 0:
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
# 若所有素材都无效,在下面统一抛 400。
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
invalid_asset_ids.append(asset_id)
continue
valid_asset_ids.append(asset_id)
asset_durations[asset_id] = _dur
# 计算 smart_match 综合评分,用于候选排序
try:
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
except Exception:
asset_smart_scores[asset_id] = 0.0
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
try:
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
asset_scene_points[asset_id] = cached_points
except Exception:
pass
if invalid_asset_ids:
logger.info(
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
len(invalid_asset_ids),
",".join(invalid_asset_ids[:5]),
)
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
if not valid_asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材尚未完成分析,请稍后重试",
)
# 后续分配素材时只在 valid_asset_ids 里挑选
unique_asset_ids = valid_asset_ids
logger.info(
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
len(asset_scene_points),
+27
View File
@@ -13,6 +13,33 @@ class ConfirmGenerationRequest(BaseModel):
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
class FinalizeGenerationRequest(BaseModel):
"""Step5 点「完成」请求体:用户选定封面后,正式将视频入成品库。"""
cover_url: str = Field(
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
)
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
class FinalizeGenerationResponse(BaseModel):
"""finalize 响应:返回新创建的成品库视频信息。"""
video_id: str = Field(description="新创建的成品视频 ID")
project_id: str = Field(default="", description="成品所属项目 ID")
name: str = Field(default="", description="成片名称")
file_size: int = Field(default=0, description="文件大小(字节)")
duration: float = Field(default=0.0, description="时长(秒)")
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
cover_url: str = Field(default="", description="最终绑定的封面 URL")
file_url: str = Field(default="", description="成品视频下载 URL")
width: int = Field(default=0)
height: int = Field(default=0)
fps: float = Field(default=0.0)
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
class CreateGenerationTaskRequest(BaseModel):
"""创建生成任务请求。
+22 -13
View File
@@ -999,26 +999,35 @@ class EditPlanService:
source_bgm_config: dict = {}
source_plan = self.get_plan(source_plan_id)
# #2034:读取源 plan 的 dedup_enabled 决定变体是否注入视觉/像素扰动
# 默认 True;关了则保留节奏模板+BGM差异化,但跳过 visual/pixel 扰动
_dedup_enabled = True
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
_dedup_enabled = bool(source_plan.config.get("dedup_enabled", True))
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
def _build_variant_config_update(idx: int) -> dict:
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。
#2034:dedup_enabled=False 时跳过 visual_perturbation/pixel_perturbation,
保留 rhythm_template 和 BGM 池分配(合理的多变体差异,不属于降重扰动)。
"""
upd: dict = {}
try:
perturbation = generate_visual_perturbation(rng)
if idx == 0:
perturbation["hflip"] = False
upd["visual_perturbation"] = perturbation
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
try:
pixel_pert = generate_pixel_perturbation(rng)
upd["pixel_perturbation"] = pixel_pert
except Exception:
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
if _dedup_enabled:
try:
perturbation = generate_visual_perturbation(rng)
if idx == 0:
perturbation["hflip"] = False
upd["visual_perturbation"] = perturbation
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
try:
pixel_pert = generate_pixel_perturbation(rng)
upd["pixel_perturbation"] = pixel_pert
except Exception:
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
if rt is not None:
upd["rhythm_template"] = rt
@@ -0,0 +1,142 @@
"""视频生成任务 finalize 服务(#2024)。
Worker 渲染+上传完成后不再自动入库,标记为 awaiting_cover;用户在 Step5 选好封面
点「完成」时由 API 调用本服务:创建 GeneratedVideo 成品库记录(复用 worker 预计算
的查重结果)、绑定封面、推进任务到 completed。
与 AI 数字人 ``ai_avatar_render_service.finalize_job`` 模式一致,
只是走 GenerationTask 而非 AiAvatarRenderJob。
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
class GenerationFinalizeError(Exception):
"""finalize 业务错误,code 供 API 层映射 HTTP 状态码。"""
def __init__(self, message: str, code: str = "FinalizeError", status_code: int = 400):
super().__init__(message)
self.code = code
self.status_code = status_code
class GenerationFinalizeService:
def __init__(self, db: Session):
self.db = db
def finalize_task(
self,
task_id: str,
user_id: str,
cover_url: Optional[str] = None,
custom_title: Optional[str] = None,
):
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
Returns:
GeneratedVideo 领域对象
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
from packages.application.generated_video_finalize import finalize_generated_video
task_repo = SQLAlchemyGenerationTaskRepository(self.db)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
task = task_repo.get(task_id)
if task is None:
raise GenerationFinalizeError(f"任务 {task_id} 不存在", "TaskNotFound", 404)
# ── 幂等:已入库直接返回 ─────────────────────────────────
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
if existing is not None:
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
_changed = False
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
existing.thumbnail_url = cover_url.strip()
task.cover_url = cover_url.strip()
_changed = True
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
existing.name = custom_title.strip()
_changed = True
if _changed:
self.db.commit()
if task.status.value != "completed":
try:
task.mark_completed(result_count=1)
if cover_url and cover_url.strip():
task.cover_url = cover_url.strip()
task_repo.update(task)
self.db.commit()
except Exception as e:
logger.warning("[finalize] 幂等补 mark_completed 失败: %s", e)
self.db.rollback()
return video_repo.get(existing.id)
# ── 状态校验 ─────────────────────────────────────────────
if task.status.value != "awaiting_cover":
raise GenerationFinalizeError(
f"任务当前状态 {task.status.value},无法 finalize(需 awaiting_cover)",
"InvalidTaskStatus",
400,
)
# ── 封面 ─────────────────────────────────────────────────
effective_cover = (cover_url or "").strip() if cover_url else (task.cover_url or "").strip()
# ── 入库+查重(复用 worker 预计算结果) ──────────────────
try:
result = finalize_generated_video(
task=task,
session=self.db,
effective_cover_url=effective_cover,
custom_name=custom_title,
)
except ValueError as e:
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
video_id = result["video_id"]
# 应用自定义标题
if custom_title and custom_title.strip():
try:
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
if _v is not None:
_v.name = custom_title.strip()
self.db.flush()
except Exception:
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
# ── 推进任务 ─────────────────────────────────────────────
task.mark_completed(result_count=1)
task.cover_url = effective_cover
# 清理 rendered_output(体积较大,入库后不再需要)
meta = dict(task.extra_meta or {})
meta.pop("rendered_output", None)
task.extra_meta = meta
task.updated_at = datetime.now(UTC)
task_repo.update(task)
self.db.commit()
video = video_repo.get(video_id)
logger.info(
"[finalize] task=%s finalized -> video=%s cover=%s dup=%s",
task_id,
video_id,
bool(effective_cover),
result.get("is_duplicate", False),
)
return video
+16 -20
View File
@@ -160,12 +160,14 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
)
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
// ── 页面标题 ─────────────────────────────────────────────────
// GenerateHeader: <h2><ThunderboltOutlined />智能剪辑</h2>
// SVG icon 可能干扰 role=heading 的 accessible name,用文本包含兜底
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
// h3 实际文案: "🎬 选择剪辑模式"(非 "选择模式"),用正则包含匹配
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
@@ -180,11 +182,8 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
@@ -192,9 +191,10 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
// (#2024: Step4 不再显示"📋 生成配置"卡片,内容区仅显示进度/错误)
// 等待底部操作栏的「✨ 确认生成视频」按钮可见即可
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeVisible({ timeout: 10000 })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
const createTask = page.waitForResponse(
@@ -324,12 +324,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
)
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
// ── 页面标题 ─────────────────────────────────────────────────
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
@@ -351,11 +350,8 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
// (#2048: PreviewCountModal 已移除)
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
@@ -363,9 +359,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
// (#2024: Step4 不再显示"📋 生成配置"卡片)
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeVisible({ timeout: 10000 })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
+1 -1
View File
@@ -161,7 +161,7 @@ test.describe("Core media upload flow", () => {
const asset = data.items.find((item) => item.name === "e2e-sample.mp4")
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
},
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
{ timeout: 90_000, intervals: [3_000, 5_000, 10_000] },
)
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
+1 -1
View File
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: "/api/v1",
timeout: 10000,
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
headers: {
"Content-Type": "application/json",
},
+2 -9
View File
@@ -3,7 +3,7 @@
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate } from "@/pages/generate/types/cover"
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
@@ -12,14 +12,7 @@ export interface CoverTemplateListResponse {
export interface CoverTemplateCreateRequest {
name: string
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
config?: CoverEditorConfig
}
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
+37
View File
@@ -0,0 +1,37 @@
import apiClient from "../client"
/** #2024 Step5 「完成」入库 —— 将 awaiting_cover 任务正式写入成品库 */
export interface FinalizeGenerationRequest {
/** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */
cover_url?: string
/** 用户自定义成片标题,非空时覆盖 rendered_output.name */
custom_title?: string
}
export interface FinalizeGenerationResponse {
video_id: string
project_id: string
name: string
file_size: number
duration: number
thumbnail_url: string
cover_url: string
file_url: string
width: number
height: number
fps: number
/** success=新建成功;already_finalized=幂等返回已有记录 */
status: string
is_duplicate: boolean
}
export const finalizeGeneration = async (
taskId: string,
params: FinalizeGenerationRequest = {},
): Promise<FinalizeGenerationResponse> => {
const response = await apiClient.post<FinalizeGenerationResponse>(
`/generation/tasks/${taskId}/finalize`,
params,
)
return response.data
}
@@ -6,17 +6,20 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
return response.data
}
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
signal,
timeout: 60_000,
})
return response.data
}
+563
View File
@@ -0,0 +1,563 @@
/**
* 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用)
* #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则
*/
.xx-cover-modal-toolbar {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.xx-cover-template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.xx-cover-template-card {
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s;
}
.xx-cover-template-card:hover {
border-color: var(--primary-color);
}
.xx-cover-template-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.xx-cover-template-thumb {
aspect-ratio: 9/16;
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
.xx-cover-template-info {
padding: 8px;
}
.xx-cover-template-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.xx-cover-template-badge {
font-size: 11px;
color: #7c3aed;
background: rgba(124, 58, 237, 0.1);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
.xx-cover-template-date {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.xx-cover-template-actions {
display: flex;
gap: 4px;
}
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981;
}
.xx-ce-font-dot--system {
background: #3b82f6;
}
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 {
top: -4px;
left: -4px;
}
.xx-ce-handle--1 {
top: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--2 {
top: -4px;
right: -4px;
}
.xx-ce-handle--3 {
top: 50%;
right: -4px;
margin-top: -4px;
}
.xx-ce-handle--4 {
bottom: -4px;
right: -4px;
}
.xx-ce-handle--5 {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--6 {
bottom: -4px;
left: -4px;
}
.xx-ce-handle--7 {
top: 50%;
left: -4px;
margin-top: -4px;
}
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
.xx-cover-template-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: #7c3aed;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
z-index: 2;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.xx-cover-template-thumb {
position: relative;
}
.xx-ce-preview-tip {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #6b7280;
}
.xx-ce-canvas {
background: #1a1a2e;
}
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
.xx-ce-canvas > div {
pointer-events: none;
}
/* Text panel wrapper */
.xx-ce-text-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Canvas base gradient layer (behind all elements) */
.xx-ce-canvas-base {
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
}
+2
View File
@@ -0,0 +1,2 @@
export { useSharedCover } from "./useSharedCover"
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
@@ -0,0 +1,297 @@
/**
* 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用)
*
* 能力:
* - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口)
* - 自动生成封面按钮点击 → 调用调用方传入的 generateFn
* - 封面编辑器弹窗状态
* - 本地封面上传文件选择
*/
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { message } from "antd"
import type { CoverTemplate } from "@/pages/generate/types/cover"
import {
fetchCoverTemplates,
createCoverTemplate,
updateCoverTemplate,
deleteCoverTemplate,
} from "@/api/cover-templates"
export interface UseSharedCoverOptions {
canGenerate: boolean
disabledHint?: string
generateFn: (templateId: string) => Promise<string | null | undefined>
initialTemplateId?: string
}
export interface UseSharedCoverReturn {
templates: CoverTemplate[]
templatesLoading: boolean
templatesError: string | null
selectedTemplateId: string
selectedTemplateName: string
handleSelectTemplate: (id: string) => void
reloadTemplates: () => void
showCoverSettings: boolean
setShowCoverSettings: (v: boolean) => void
showCoverEditor: boolean
setShowCoverEditor: (v: boolean) => void
editingTemplate: CoverTemplate | null
handleEditTemplate: (tpl: CoverTemplate) => void
handleCreateTemplate: () => void
handleSaveTemplate: (tpl: CoverTemplate) => Promise<void>
handleDeleteTemplate: (id: string) => Promise<void>
generating: boolean
generateAutoCover: () => Promise<void>
uploadInputRef: React.RefObject<HTMLInputElement>
handleUploadClick: () => void
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
setOnUploadFile: (fn: (file: File) => Promise<string | null> | string | null) => void
}
export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn {
const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts
const [generating, setGenerating] = useState(false)
const [showCoverSettings, setShowCoverSettings] = useState(false)
const [showCoverEditor, setShowCoverEditor] = useState(false)
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(initialTemplateId)
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
const [templates, setTemplates] = useState<CoverTemplate[]>([])
const [templatesLoading, setTemplatesLoading] = useState(false)
const [templatesError, setTemplatesError] = useState<string | null>(null)
const uploadInputRef = useRef<HTMLInputElement>(null)
const onUploadFileRef = useRef<
((file: File) => Promise<string | null> | string | null) | undefined
>(undefined)
const setOnUploadFile = useCallback(
(fn: (file: File) => Promise<string | null> | string | null) => {
onUploadFileRef.current = fn
},
[],
)
const reloadTemplates = useCallback(async () => {
setTemplatesLoading(true)
setTemplatesError(null)
try {
const res = await fetchCoverTemplates()
// 兼容两种响应:{items:[...]} 或直接数组
const rawList = (res as unknown as { items?: CoverTemplate[] }).items ?? []
// 确保每个模板都有 config 字段(避免编辑器打开时访问 cfg.title.text 崩溃)
const list: CoverTemplate[] = rawList.map((t) => ({
...t,
config: t.config,
}))
setTemplates(list)
} catch (err) {
const axiosErr = err as {
response?: {
status?: number
data?: { detail?: string; message?: string; error?: { message?: string } }
}
message?: string
}
const status = axiosErr?.response?.status
const detail =
axiosErr?.response?.data?.detail ||
axiosErr?.response?.data?.message ||
axiosErr?.response?.data?.error?.message ||
axiosErr?.message
console.error("[SharedCover] 加载封面模板失败:", err, "status=", status, "detail=", detail)
if (status === 401) {
setTemplatesError("登录已过期,请刷新页面重新登录")
} else if (status === 403) {
setTemplatesError(detail ? "权限不足:" + detail : "无权限访问封面模板")
} else {
setTemplatesError("加载模板失败:" + (detail || "请稍后重试"))
}
} finally {
setTemplatesLoading(false)
}
}, [])
useEffect(() => {
if (showCoverSettings) {
void reloadTemplates()
}
}, [showCoverSettings, reloadTemplates])
const handleSelectTemplate = useCallback((id: string) => {
setSelectedTemplateId(id)
}, [])
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
// 系统模板不可修改:复制为新模板草稿,走另存为流程
if (tpl.is_system) {
setEditingTemplate({
...tpl,
id: "",
name: tpl.name + " 副本",
is_system: false,
created_at: "",
})
} else {
setEditingTemplate(tpl)
}
setShowCoverEditor(true)
}, [])
const handleCreateTemplate = useCallback(() => {
setEditingTemplate(null)
setShowCoverEditor(true)
}, [])
const handleSaveTemplate = useCallback(
async (tpl: CoverTemplate) => {
try {
// 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新
const isSystem = templates.find((t) => t.id === tpl.id)?.is_system === true
const shouldCreate = !tpl.id || isSystem
if (shouldCreate) {
const created = await createCoverTemplate({
name: tpl.name || "我的封面模板",
config: tpl.config,
})
setTemplates((prev) => [...prev, created])
setSelectedTemplateId(created.id || tpl.id)
} else {
const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config })
setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
}
setShowCoverEditor(false)
setEditingTemplate(null)
} catch (err) {
const axiosErr = err as {
response?: {
status?: number
data?: { detail?: string; message?: string; error?: { message?: string } }
}
message?: string
}
const status = axiosErr?.response?.status
const detail =
axiosErr?.response?.data?.detail ||
axiosErr?.response?.data?.message ||
axiosErr?.response?.data?.error?.message ||
axiosErr?.message
console.error("[SharedCover] 保存模板失败:", err, "status=", status, "detail=", detail)
if (status === 403) {
message.error("保存失败(权限不足):" + (detail || "无权操作该模板"))
} else {
message.error("保存模板失败:" + (detail || "请稍后重试"))
}
}
},
[templates],
)
const handleDeleteTemplate = useCallback(
async (id: string) => {
try {
await deleteCoverTemplate(id)
setTemplates((prev) => prev.filter((t) => t.id !== id))
if (selectedTemplateId === id) {
setSelectedTemplateId("default")
}
} catch (err) {
const axiosErr = err as {
response?: {
status?: number
data?: { detail?: string; message?: string; error?: { message?: string } }
}
message?: string
}
const status = axiosErr?.response?.status
const detail =
axiosErr?.response?.data?.detail ||
axiosErr?.response?.data?.message ||
axiosErr?.response?.data?.error?.message ||
axiosErr?.message
console.error("[SharedCover] 删除模板失败:", err, "status=", status, "detail=", detail)
if (status === 403) {
message.error("删除失败(权限不足):" + (detail || "无权操作该模板"))
} else {
message.error("删除模板失败:" + (detail || "请稍后重试"))
}
}
},
[selectedTemplateId],
)
const generateAutoCover = useCallback(async () => {
if (generating) {
message.warning("封面正在生成中,请稍候…")
return
}
if (!canGenerate) {
if (disabledHint) message.warning(disabledHint)
return
}
setGenerating(true)
try {
const url = await generateFn(selectedTemplateId || "default")
if (!url) {
message.warning("封面生成未返回图片,请重试")
}
} catch (err) {
console.error("[SharedCover] 自动生成封面失败:", err)
const anyErr = err as { __msgShown?: boolean; message?: string }
if (!anyErr?.__msgShown) {
message.error(anyErr?.message || "封面生成失败")
}
} finally {
setGenerating(false)
}
}, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId])
const handleUploadClick = useCallback(() => {
uploadInputRef.current?.click()
}, [])
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
if (onUploadFileRef.current) {
const ret = onUploadFileRef.current(file)
if (ret instanceof Promise) {
ret.catch((err) => {
console.error("[SharedCover] 上传封面失败:", err)
})
}
}
}, [])
const selectedTemplateName =
templates.find((t) => t.id === selectedTemplateId)?.name ||
(selectedTemplateId === "default" ? "默认模板" : "自定义")
return {
templates,
templatesLoading,
templatesError,
selectedTemplateId,
selectedTemplateName,
handleSelectTemplate,
reloadTemplates,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
setShowCoverEditor,
editingTemplate,
handleEditTemplate,
handleCreateTemplate,
handleSaveTemplate,
handleDeleteTemplate,
generating,
generateAutoCover,
uploadInputRef,
handleUploadClick,
handleFileInputChange,
setOnUploadFile,
}
}
export default useSharedCover
+58 -7
View File
@@ -61,6 +61,10 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 对口型耗时计时(秒) ── */
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
const lipsyncStartAtRef = useRef<number>(0)
const lipsyncTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -222,6 +226,13 @@ const AiAvatarPage: React.FC = () => {
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// 启动计时器
lipsyncStartAtRef.current = Date.now()
setLipsyncElapsed(0)
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = setInterval(() => {
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
}, 1000)
const asset = await getAssetById(video.id)
const videoUrl = asset?.file_url
@@ -265,6 +276,11 @@ const AiAvatarPage: React.FC = () => {
state.setLipsyncJob(updated)
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
setLipsyncStatus("completed")
setTimeout(() => {
setShowLipsyncModal(false)
@@ -272,6 +288,10 @@ const AiAvatarPage: React.FC = () => {
}, 1000)
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncStatus("failed")
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
@@ -285,6 +305,10 @@ const AiAvatarPage: React.FC = () => {
data: (err as { response?: { data?: unknown } })?.response?.data,
message: err instanceof Error ? err.message : String(err),
})
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setShowLipsyncModal(false)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
@@ -305,15 +329,21 @@ const AiAvatarPage: React.FC = () => {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setShowLipsyncModal(false)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
setLipsyncElapsed(0)
}, [])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
}
}, [])
@@ -696,17 +726,11 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-panel__body">
{currentRenderJob?.status !== "completed" ? (
<PanelCoverAndGenerate
variant="setup"
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
renderJob={currentRenderJob}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
renderJob={currentRenderJob}
summary={summary}
/>
) : (
@@ -963,6 +987,19 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成中…
</div>
<div
style={{
marginTop: 12,
fontSize: 28,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
color: "#7c3aed",
}}
>
{`${Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`}
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
请勿关闭页面,完成后将自动提示
</div>
@@ -974,6 +1011,20 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成完成
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#10b981",
fontVariantNumeric: "tabular-nums",
}}
>
总耗时{" "}
{Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}
:{(lipsyncElapsed % 60).toString().padStart(2, "0")}
</div>
</>
)}
{lipsyncStatus === "failed" && (
+7 -4
View File
@@ -99,15 +99,18 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
*/
export const generateRenderSmartCover = async (
jobId: string,
templateId: string = "default",
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
`/ai-avatar/render/${jobId}/smart-cover`,
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
templateId && templateId !== "default" ? { template_id: templateId } : {},
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
)
return response.data
}
@@ -1,11 +1,25 @@
/**
* AI数字人 — 封面选择弹窗
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
* AI数字人 — 封面选择弹窗(#2033 共享封面组件重构)
*
* 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器)
* + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。
*
* 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构:
* - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url
* - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL
*
* 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。
*/
import React from "react"
import React, { useCallback, useEffect, useMemo } from "react"
import { Modal as AntModal, Spin, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal"
import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
interface ModalCoverSelectProps {
open: boolean
@@ -13,7 +27,13 @@ interface ModalCoverSelectProps {
renderJob: RenderJob | null
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
/**
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
*/
onGenerateRenderSmartCover?: (
renderId: string,
) => Promise<{ cover_url: string; message?: string }>
onUploadCover?: (file: File) => void
onCoverSelected: (coverUrl: string) => void
}
@@ -28,31 +48,302 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
onUploadCover,
onCoverSelected,
}) => {
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
const generateFn = useCallback(
async (templateId: string): Promise<string | null> => {
if (!renderJob || !isRenderCompleted) return null
try {
let coverUrl = ""
if (onGenerateRenderSmartCover) {
const res = await onGenerateRenderSmartCover(renderJob.id)
coverUrl = res.cover_url
} else {
const res = await apiGenerateSmartCover(renderJob.id, templateId)
coverUrl = res.cover_url
if (!coverUrl && res.message) {
const err = new Error(res.message) as Error & { __msgShown?: boolean }
err.__msgShown = true
message.error(res.message)
throw err
}
}
if (coverUrl) {
onCoverConfigChange({
mode: "auto_frame",
thumbnail_url: coverUrl,
smart_cover_url: coverUrl,
})
onCoverSelected(coverUrl)
message.success("智能封面已生成")
}
return coverUrl || null
} catch (err) {
const anyErr = err as { __msgShown?: boolean; message?: string }
if (!anyErr?.__msgShown) {
message.error(anyErr?.message || "智能封面生成失败")
}
throw err
}
},
[
renderJob,
isRenderCompleted,
onGenerateRenderSmartCover,
onCoverConfigChange,
onCoverSelected,
],
)
const shared = useSharedCover({
canGenerate: isRenderCompleted,
disabledHint: "请先完成视频生成再选择封面",
initialTemplateId: "default",
generateFn,
})
// 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件)
useEffect(() => {
shared.setOnUploadFile((file: File) => {
if (onUploadCover) {
onUploadCover(file)
} else {
const url = URL.createObjectURL(file)
onCoverConfigChange({
mode: "upload",
upload_url: url,
thumbnail_url: url,
})
onCoverSelected(url)
}
return null
})
}, [shared, onUploadCover, onCoverConfigChange, onCoverSelected])
// 打开时同步刷新模板列表
useEffect(() => {
if (open) void shared.reloadTemplates()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
/** 当前预览 URL:智能封面 > 自定义上传 */
const previewUrl = useMemo(
() => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "",
[coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url],
)
if (!open) return null
return (
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
<div className="aa-modal__header">
<span className="aa-modal__title">选择封面</span>
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
×
</button>
<Modal
open={open}
onCancel={onClose}
title="选择封面"
width={560}
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确定
</Button>
</div>
<div className="aa-modal__body" style={{ padding: 20 }}>
<PanelCoverAndGenerate
variant="select-cover"
coverConfig={coverConfig}
onCoverConfigChange={onCoverConfigChange}
renderJob={renderJob}
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
onUploadCover={onUploadCover}
onClose={onClose}
onCoverSelected={onCoverSelected}
/>
}
>
<div style={{ padding: "8px 0" }}>
{renderJob && (
<div
style={{
padding: "8px 12px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 从渲染成片中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
}}
>
{/* 左:封面预览 */}
<div
style={{
width: 180,
flexShrink: 0,
}}
>
<div
className="xx-ce-canvas"
style={{
position: "relative",
width: "100%",
aspectRatio: "9 / 16",
borderRadius: 8,
overflow: "hidden",
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
border: previewUrl ? "none" : "1px dashed #d9d9d9",
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="封面预览"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
gap: 6,
opacity: 0.7,
}}
>
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
</span>
</div>
)}
{shared.generating && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
flexDirection: "column",
gap: 8,
}}
>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>AI 选帧中…</span>
</div>
)}
</div>
<div
style={{
marginTop: 6,
textAlign: "center",
fontSize: 11,
color: "#8c8ca1",
}}
>
9:16 竖版封面
</div>
</div>
{/* 右:操作按钮 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!isRenderCompleted || shared.generating}
loading={shared.generating}
style={{ width: "100%" }}
>
✨ 自动生成封面
</Button>
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={{ width: "100%" }}
>
⚙️ 封面模板
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={!isRenderCompleted || shared.generating}
style={{ width: "100%" }}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div
style={{
fontSize: 11,
color: "#8c8ca1",
lineHeight: 1.5,
marginTop: 4,
padding: "6px 8px",
background: "#f7f8fa",
borderRadius: 6,
}}
>
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
</div>
</div>
</div>
{/* 模板选择弹窗 */}
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
/>
{/* 自定义编辑器弹窗 */}
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</Modal>
)
}
@@ -1,36 +1,19 @@
/**
* AI数字人 — 面板5 / 封面选择弹窗内容:
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
*
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
* AI数字人 — 面板5 / 生成配置面板(渲染前)
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
*/
import React, { useRef, useState } from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
type PanelVariant = "setup" | "select-cover"
import React from "react"
import type { RenderJob } from "../types"
interface PanelCoverAndGenerateProps {
variant?: PanelVariant
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
resolution?: string
onResolutionChange?: (r: string) => void
isGenerating?: boolean
onGenerate?: () => void
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
/** 当前渲染任务 */
renderJob: RenderJob | null
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
onUploadCover?: (file: File) => void
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
onClose?: () => void
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
onCoverSelected?: (coverUrl: string) => void
/** 配置汇总信息(仅 variant="setup" 使用) */
/** 配置汇总信息 */
summary?: {
videoName: string | null
voiceName: string | null
@@ -38,7 +21,6 @@ interface PanelCoverAndGenerateProps {
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
}
}
@@ -58,89 +40,15 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
}
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
variant = "setup",
coverConfig,
onCoverConfigChange,
resolution = "720p",
onResolutionChange,
isGenerating = false,
onGenerate,
renderJob,
onGenerateRenderSmartCover,
onUploadCover,
onClose,
onCoverSelected,
renderJob: _renderJob,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/** 自定义上传封面 */
const handleUploadClick = () => {
uploadInputRef.current?.click()
}
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
const partial: Partial<AiAvatarCoverConfig> = {
mode,
thumbnail_url: url,
}
if (mode === "auto_frame") {
partial.smart_cover_url = url
} else {
partial.upload_url = url
}
onCoverConfigChange(partial)
onCoverSelected?.(url)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (onUploadCover) {
onUploadCover(file)
e.target.value = ""
return
}
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file)
_applyCoverUrl(url, "upload")
e.target.value = ""
}
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
const handleSmartCover = async () => {
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
setSmartCoverLoading(true)
try {
const res = await onGenerateRenderSmartCover(renderJob.id)
if (res.cover_url) {
_applyCoverUrl(res.cover_url, "auto_frame")
} else {
// 失败由父组件 message 提示,这里不重复弹窗
console.warn("[智能封面] 返回空 cover_url:", res.message)
}
} catch (err) {
console.error("[智能封面] 调用失败:", err)
} finally {
setSmartCoverLoading(false)
}
}
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
/** 封面区占位文字 */
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
/** 配置摘要中的封面状态标签 */
const coverSummaryNode = (() => {
@@ -154,69 +62,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
return <span className="aa-config-summary__empty">生成视频后可选</span>
})()
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
const coverSection = (
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
{variant === "select-cover" ? "选择封面" : "封面"}
</div>
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={!canSmartCover}
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷 自定义上传
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
if (variant === "select-cover") {
return (
<div className="aa-cover-generate">
{coverSection}
{onClose && (
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
确定
</button>
</div>
)}
</div>
)
}
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
return (
<div className="aa-cover-generate">
{/* 分辨率选择 */}
+56 -61
View File
@@ -14,14 +14,13 @@ import VoiceSelectModal from "./components/VoiceSelectModal"
import ScriptSelectModal from "./components/ScriptSelectModal"
import TtsVoiceModal from "./components/TtsVoiceModal"
import GenerateHeader from "./components/GenerateHeader"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { confirmGeneration } from "@/api/generation/confirm"
import { finalizeGeneration } from "@/api/generation/finalize"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
@@ -107,6 +106,7 @@ const GeneratePage: React.FC = () => {
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
setSelectedTemplate,
} = formState
const isBatch = previewCount > 1
@@ -137,9 +137,6 @@ const GeneratePage: React.FC = () => {
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
@@ -199,6 +196,7 @@ const GeneratePage: React.FC = () => {
generated,
generateError,
generatedVideos,
currentTaskId,
batchTasks,
generate: handleGenerate,
retry: handleRetryGenerate,
@@ -244,38 +242,37 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 数量弹窗确认 ── */
const handleCountConfirm = useCallback(
(count: number) => {
setPreviewCount(count)
setCountModalOpen(false)
setPreviewTitles((prev) => {
const list = prev || []
const base = list[0] || titleSettings.title || ""
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
setCurrentStep(3)
},
[
setPreviewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
setCurrentStep,
titleSettings.title,
selectedVoice,
],
)
/* ── 对齐批量数组长度到 previewCount(用于进入 Step3 时) ── */
const ensureArraysAligned = useCallback(() => {
setPreviewTitles((prev) => {
const list = prev || []
if (list.length === previewCount) return list
const base = list[0] || titleSettings.title || ""
return Array.from({ length: previewCount }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
if (list.length === previewCount) return list
return Array.from({ length: previewCount }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
if (list.length === previewCount) return list
return Array.from({ length: previewCount }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds((prev) => {
if (prev && prev.length === previewCount) return prev
return Array.from({ length: previewCount }, (_, i) => i)
})
}, [
previewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
titleSettings.title,
selectedVoice,
])
/* ── #1970:Step1 弹窗回调 ── */
const handleVoiceModalConfirm = useCallback(
@@ -398,7 +395,7 @@ const GeneratePage: React.FC = () => {
smartSelectedIds,
titleSettings,
generated,
onOpenCountModal: () => setCountModalOpen(true),
onBeforeEnterStep3: ensureArraysAligned,
onOpenStep1Modal: () => {
if (editMode === "random") {
setVoiceModalOpen(true)
@@ -411,7 +408,7 @@ const GeneratePage: React.FC = () => {
/* ── 最终成片(单视频) ── */
const finalVideo = generatedVideos[0]
/* ── Step5 完成:调用 confirm 入库 + 跳转 ── */
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
const handleFinish = useCallback(async () => {
if (finishing) return
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
@@ -429,31 +426,33 @@ const GeneratePage: React.FC = () => {
setFinishing(true)
const hide = message.loading("正在保存到视频库...", 0)
try {
const taskIds =
batchTasks && batchTasks.length > 0
? batchTasks.map((t) => t.taskId).filter(Boolean)
: finalVideo?.generation_task_id
? [finalVideo.generation_task_id]
: []
// 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId
const singleTaskId = finalVideo?.generation_task_id || currentTaskId || ""
// 单视频/批量:为每个任务调用 confirm(传入封面)
if (isBatch && previewCovers.length > 0) {
// 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题)
// 批量时必须按 batchTasks[i].variantIndex 对齐 previewCovers/previewTitles(taskIds 顺序不一定按变体序号)
if (isBatch && batchTasks.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return confirmGeneration(taskId, {
batchTasks.map(async (task) => {
const vi = task.variantIndex
const coverUrl = previewCovers[vi] || ""
const title = previewTitles[vi] || titleSettings.title || ""
return finalizeGeneration(task.taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
custom_title: title,
})
}),
)
} else if (finalVideo?.generation_task_id) {
} else if (singleTaskId) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await confirmGeneration(finalVideo.generation_task_id, {
await finalizeGeneration(singleTaskId, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
} else {
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
}
hide()
message.success("已保存到视频库")
navigate("/app/products")
@@ -480,6 +479,7 @@ const GeneratePage: React.FC = () => {
previewTitles,
titleSettings.title,
coverSettings,
currentTaskId,
navigate,
])
@@ -576,6 +576,9 @@ const GeneratePage: React.FC = () => {
previewCovers={previewCovers}
onPreviewCoversChange={setPreviewCovers}
selectedVariantIds={selectedVariantIds}
selectedCoverTemplate={selectedTemplate}
onSelectedCoverTemplateChange={setSelectedTemplate}
onConfirmGenerate={handleConfirmGenerate}
/>
{/* ════ 步骤4(单视频):成片播放器 ════ */}
@@ -663,14 +666,6 @@ const GeneratePage: React.FC = () => {
</div>
</div>
{/* 数量选择弹窗 */}
<PreviewCountModal
open={countModalOpen}
defaultCount={1}
onConfirm={handleCountConfirm}
onCancel={() => setCountModalOpen(false)}
/>
{/* 音色克隆弹窗 */}
<CloneModal
open={cloneModalOpen}
@@ -37,7 +37,9 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-preview-header">
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
完成{" "}
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
{tasks.length}
</span>
</div>
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
@@ -49,7 +51,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" ? (
{task.status === "completed" || task.status === "awaiting_cover" ? (
<CheckCircleFilled
className="xx-batch-gen-card-icon"
style={{ color: "#52c41a" }}
@@ -83,7 +85,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
</>
)}
{task.status === "completed" && video && (
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
@@ -111,7 +113,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
/>
</div>
)}
{task.status === "completed" && !video && (
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
)}
{task.status === "failed" && (
@@ -89,6 +89,10 @@ export interface GenerateStepContentProps {
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
selectedVariantIds?: number[]
selectedCoverTemplate?: string
onSelectedCoverTemplateChange?: (templateId: string) => void
/** Step3 右上角确认生成按钮 */
onConfirmGenerate?: () => void | Promise<void>
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -142,6 +146,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedCoverTemplate,
onSelectedCoverTemplateChange,
onConfirmGenerate,
} = props
switch (currentStep) {
@@ -195,6 +202,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
onConfirmGenerate={onConfirmGenerate}
generating={props.generating}
selectedCount={
props.previewCount && props.previewCount > 1 ? props.selectedVariantIds?.length || 1 : 1
}
/>
)
case 4:
@@ -250,6 +262,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers={previewCovers}
onPreviewCoversChange={onPreviewCoversChange}
selectedVariantIndexes={selectedVariantIds}
selectedTemplate={selectedCoverTemplate}
onTemplateChange={onSelectedCoverTemplateChange}
/>
)
default:
@@ -1,127 +0,0 @@
/**
* 生成数量选择弹窗(Issue #1677)
* Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10)
* 默认 1,回车 = 1(零额外操作)
*/
import React, { useState, useEffect, useRef } from "react"
import { MAX_PREVIEW_COUNT } from "../constants"
interface PreviewCountModalProps {
open: boolean
/** 默认值(上次选择,默认1) */
defaultCount?: number
onConfirm: (count: number) => void
onCancel: () => void
}
const PreviewCountModal: React.FC<PreviewCountModalProps> = ({
open,
defaultCount = 1,
onConfirm,
onCancel,
}) => {
const [count, setCount] = useState(defaultCount)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setCount(defaultCount)
// 弹窗打开后聚焦并选中,方便直接回车=默认1
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, defaultCount])
const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1))
const handleConfirm = () => {
onConfirm(clamp(count))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleConfirm()
}
if (e.key === "Escape") {
onCancel()
}
}
if (!open) return null
return (
<div className="xx-modal-mask" onClick={onCancel}>
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}>要生成几个视频?</h3>
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面
</p>
<div className="xx-count-selector">
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c - 1))}
disabled={count <= 1}
aria-label="减少"
>
−
</button>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_PREVIEW_COUNT}
value={count}
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
onKeyDown={handleKeyDown}
className="xx-count-input"
/>
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c + 1))}
disabled={count >= MAX_PREVIEW_COUNT}
aria-label="增加"
>
+
</button>
</div>
<div className="xx-count-quick">
{[1, 3, 5, 10].map((n) => (
<button
key={n}
type="button"
className={`xx-count-chip ${count === n ? "active" : ""}`}
onClick={() => setCount(n)}
>
{n} 个
</button>
))}
</div>
<div className="xx-count-actions">
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
取消
</button>
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
</button>
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--text-tertiary, #999)",
textAlign: "center",
}}
>
直接按回车 = 生成 1 个
</p>
</div>
</div>
)
}
export default PreviewCountModal
@@ -47,6 +47,12 @@ interface Step4TitleSettingsProps {
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
/** Step3 右上角「🎬 确认生成」主按钮 */
onConfirmGenerate?: () => void | Promise<void>
/** 是否生成中 */
generating?: boolean
/** 批量模式下勾选数量 */
selectedCount?: number
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -69,6 +75,9 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
enableTemplates,
selectedTemplateId,
onApplyTemplate,
onConfirmGenerate,
generating,
selectedCount = 1,
} = props
const isBatch = previewCount > 1
@@ -90,7 +99,47 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
)
return (
<div className="xx-form-section">
<div className="xx-form-section" style={{ position: "relative" }}>
{/* ── 右上角「🎬 确认生成」主按钮 ── */}
{onConfirmGenerate && (
<button
type="button"
onClick={() => {
if (generating) return
void onConfirmGenerate()
}}
disabled={generating}
style={{
position: "absolute",
top: 0,
right: 0,
background: generating ? "#a78bfa" : "#7c3aed",
color: "#fff",
border: "none",
borderRadius: 10,
padding: "12px 24px",
fontSize: 15,
fontWeight: 600,
cursor: generating ? "not-allowed" : "pointer",
boxShadow: "0 4px 14px rgba(124,58,237,0.4)",
transition: "all .2s",
zIndex: 5,
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#6d28d9"
}}
onMouseLeave={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#7c3aed"
}}
>
{generating
? "⏳ 生成中..."
: selectedCount > 1
? `🎬 确认生成 ${selectedCount} 个视频`
: "🎬 确认生成"}
</button>
)}
<h3>📝 选择标题</h3>
{!isBatch ? (
@@ -1,84 +1,151 @@
/**
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
* - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传
*
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
*/
import React, { useRef } from "react"
import React, { useEffect, useMemo } from "react"
import { Modal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { useBatchCovers } from "../hooks/useBatchCovers"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateCover as apiGenerateCover } from "@/api/generation"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
/* ── 批量生成(#1677)── */
previewCount?: number
/** 每个变体的标题文字 */
previewTitles?: string[]
/** 每个变体的封面URL(按变体索引) */
previewCovers?: string[]
onPreviewCoversChange?: (urls: string[]) => void
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
selectedVariantIndexes?: number[]
onTemplateChange?: (templateId: string) => void
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const {
coverSettings,
generating,
generateAutoCover,
finalVideo,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
setShowCoverEditor,
selectedTemplateId,
editingTemplate,
coverTemplates,
templatesLoading,
templatesError,
handleSelectTemplate,
handleEditTemplate,
handleSaveTemplate,
handleDeleteTemplate,
} = useStep6Cover({
coverSettings: props.coverSettings,
onCoverSettingsChange: props.onCoverSettingsChange,
selectedTemplate: props.selectedTemplate,
titleSettings: props.titleSettings,
generatedVideos: props.generatedVideos,
})
const previewCount = props.previewCount || 1
const isBatch = previewCount > 1
const previewTitles = props.previewTitles || []
const previewCovers = props.previewCovers || []
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
/** 最终成片:取第一个已完成视频(单视频场景) */
const finalVideo =
props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
props.generatedVideos[0]
const completedVideos = useMemo(
() =>
props.generatedVideos.filter(
(v) => v.status === "completed" || v.status === "awaiting_cover",
),
[props.generatedVideos],
)
/**
* 单视频自动生成(点击"自动生成封面"按钮):使用当前选中的模板
* 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发
*/
const shared = useSharedCover({
canGenerate:
!!finalVideo &&
!isBatch &&
(finalVideo.status === "completed" ||
finalVideo.status === "awaiting_cover" ||
!finalVideo.status),
disabledHint: isBatch ? "批量场景请在上方操作卡片" : "请先生成视频再选择封面",
initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default
generateFn: async (tplId) => {
if (!finalVideo || isBatch) return null
const response = await apiGenerateCover(tplId, {
generated_video_id:
(finalVideo as { id?: string; video_id?: string }).id ||
(finalVideo as { video_id?: string }).video_id ||
"",
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
})
const url = response.cover?.image_url || ""
if (url) {
props.onCoverSettingsChange({
...props.coverSettings,
thumbnail_url: url,
ai_suggested_time: response.cover?.frame_time ?? null,
})
}
return url
},
})
// 选中模板变化时通知父组件(用于批量生成时透传 template_id)
const { onTemplateChange, selectedTemplate: parentSelectedTemplate } = props
// 父组件 selectedTemplate 变化时同步到子(例如从 Step1/Step4 切换到 Step6 时)
useEffect(() => {
if (parentSelectedTemplate && parentSelectedTemplate !== shared.selectedTemplateId) {
shared.handleSelectTemplate(parentSelectedTemplate)
}
}, [parentSelectedTemplate]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isBatch && onTemplateChange && shared.selectedTemplateId !== parentSelectedTemplate) {
onTemplateChange(shared.selectedTemplateId)
}
}, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange])
useEffect(() => {
shared.setOnUploadFile(() => null)
}, [shared])
const batchUploadRef = React.useRef<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
const handleBatchUploadClick = (cardPos: number) => {
setBatchUploadCard(cardPos)
batchUploadRef.current?.click()
}
const handleBatchUploadChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
const cardPos = batchUploadCard
setBatchUploadCard(null)
if (!file || cardPos == null) return
void batchCovers.uploadOne(cardPos, file)
}
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
/**
* 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板,
* 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。
*/
const batchCovers = useBatchCovers({
selectedTemplate: props.selectedTemplate || "",
selectedTemplate: shared.selectedTemplateId || "default",
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
@@ -92,7 +159,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
covers: batchCoversList,
onCoversChange: (updater) => {
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(props.previewCovers || [])]
@@ -103,22 +169,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
})
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) {
const variantIndex = uploadTargetRef.current
const cardPos = cardIndexes.indexOf(variantIndex)
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
}
}
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
/* ── 批量封面 ── */
if (isBatch) {
@@ -138,17 +189,32 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" · "}当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
loading={batchCovers.busyIndexes.length > 0}
>
✨ 一键全部自动生成
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
? `:${shared.selectedTemplateName}`
: ""}
</Button>
<Button buttonType="ghost" onClick={shared.handleCreateTemplate}>
➕ 新建模板
</Button>
</div>
<div className="xx-cover-grid">
@@ -209,7 +275,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleUploadClick(variantIndex)}
onClick={() => handleBatchUploadClick(cardPos)}
disabled={isLoading || isUploading}
>
📤 上传
@@ -220,23 +286,43 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
})}
</div>
{/* 隐藏的文件选择 input,批量上传复用 */}
<input
ref={uploadInputRef}
ref={batchUploadRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
onChange={handleBatchUploadChange}
/>
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
</div>
)
}
/* ── 单视频:原有流程保持不变 ── */
/* ── 单视频 ── */
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{/* 最终成片信息 */}
{finalVideo && (
<div
style={{
@@ -250,16 +336,55 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div className="xx-cover-actions">
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!finalVideo || shared.generating}
loading={shared.generating}
>
✨ 自动生成封面
</Button>
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
⚙️ 封面设置
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
</Button>
<Button buttonType="ghost" onClick={shared.handleUploadClick}>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
const url = URL.createObjectURL(file)
props.onCoverSettingsChange({
...props.coverSettings,
upload_url: url,
thumbnail_url: url,
mode: "upload",
})
}}
/>
<input
ref={batchUploadRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
</div>
<div className="xx-section-title">封面预览</div>
@@ -276,30 +401,26 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div>
<CoverSettingsModal
open={showCoverSettings}
onClose={() => setShowCoverSettings(false)}
templates={coverTemplates}
loading={templatesLoading}
error={templatesError}
selectedTemplateId={selectedTemplateId}
onSelectTemplate={handleSelectTemplate}
onEditTemplate={handleEditTemplate}
onDeleteTemplate={handleDeleteTemplate}
onCreateNew={() => {
setShowCoverSettings(false)
setShowCoverEditor(true)
}}
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
/>
<CoverEditorModal
open={showCoverEditor}
onClose={() => setShowCoverEditor(false)}
template={editingTemplate}
onSave={handleSaveTemplate}
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
{/* AI 生成封面进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<Modal open={shared.generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
@@ -311,4 +432,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)
}
Step6CoverSettings.displayName = "Step6CoverSettings"
export default Step6CoverSettings
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ import React from "react"
import type { CoverTemplate } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import "@/components/cover/cover.css"
interface CoverSettingsModalProps {
open: boolean
@@ -40,12 +41,26 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
onCreateNew,
}) => {
return (
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
<Modal
open={open}
onCancel={onClose}
width={800}
title="封面设置"
centered
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确认应用
</Button>
</div>
}
>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary">选择素材文件</Button>
<Button buttonType="ghost">导出全部</Button>
<Button buttonType="primary" onClick={onCreateNew}>
创建新模板
+ 创建新模板
</Button>
</div>
@@ -59,52 +74,83 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && (
{!loading && !error && templates.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-secondary)",
fontSize: 13,
}}
>
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
</div>
)}
{!loading && !error && templates.length > 0 && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => (
<div
key={tpl.id}
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
<div
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
🖼️
</div>
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
<div
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
>
{isSelected && <span className="xx-cover-template-check">✓</span>}
🖼️
</div>
<div className="xx-cover-template-date">{tpl.created_at}</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
编辑
</Button>
{!tpl.is_system && (
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
>
删除
编辑
</Button>
)}
<Button buttonType="ghost" buttonSize="sm">
导出
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</div>
</div>
</div>
</div>
))}
)
})}
</div>
)}
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "rgba(124,58,237,0.06)",
borderRadius: 6,
fontSize: 12,
color: "#6d28d9",
}}
>
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
</div>
</Modal>
)
}
+449
View File
@@ -2739,6 +2739,7 @@
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
/* 卡片信息区 */
@@ -3335,3 +3336,451 @@
grid-template-columns: minmax(0, 360px);
}
}
/* ================================================================
自定义封面编辑器 (Cover Editor Modal) — xx-ce-*
================================================================ */
/* Header */
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Layout */
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
/* Section / collapsible panels */
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
/* Rows / labels */
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* Color picker */
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
/* Position pair */
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
/* Radio button group */
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
/* Font select dots */
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981;
}
.xx-ce-font-dot--system {
background: #3b82f6;
}
/* Shadow actions */
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
/* Text background sub-section */
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
/* Readonly text display */
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
/* Mask file row */
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
/* ── Canvas / Preview ── */
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
/* Portrait element */
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 {
top: -4px;
left: -4px;
}
.xx-ce-handle--1 {
top: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--2 {
top: -4px;
right: -4px;
}
.xx-ce-handle--3 {
top: 50%;
right: -4px;
margin-top: -4px;
}
.xx-ce-handle--4 {
bottom: -4px;
right: -4px;
}
.xx-ce-handle--5 {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--6 {
bottom: -4px;
left: -4px;
}
.xx-ce-handle--7 {
top: 50%;
left: -4px;
margin-top: -4px;
}
/* Background element */
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
/* Mask overlay */
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
/* Text background shape in canvas */
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
/* Cover template selected check */
.xx-cover-template-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: #7c3aed;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
z-index: 2;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.xx-cover-template-thumb {
position: relative;
}
/* Preview tip */
.xx-ce-preview-tip {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #6b7280;
}
/* Cover editor modal base gradient */
.xx-ce-canvas {
background: #1a1a2e;
}
/* Antd Slider overrides for editor */
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
/* Antd Select dropdown font dots */
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
/* Canvas text elements — ensure proper stacking */
.xx-ce-canvas > div {
pointer-events: none;
}
@@ -10,7 +10,7 @@ export interface BatchTaskState {
taskId: string
/** 变体序号(0-based,与标题/封面数组对齐) */
variantIndex: number
status: "running" | "completed" | "failed"
status: "running" | "completed" | "awaiting_cover" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
@@ -96,7 +96,7 @@ export function useGenerationPolling({
runId: number,
callbacks?: {
onTaskProgress?: (pct: number) => void
onTaskCompleted?: (videos: unknown[]) => void
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onTaskFailed?: (msg: string) => void
},
): Promise<unknown[]> => {
@@ -111,7 +111,7 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed") {
if (task.status === "completed" || task.status === "awaiting_cover") {
done = true
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
@@ -121,7 +121,7 @@ export function useGenerationPolling({
reject(new Error(msg))
return
}
callbacks?.onTaskCompleted?.(videos)
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
resolve(videos)
return
}
@@ -259,10 +259,11 @@ export function useGenerationPolling({
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
reportAggregateProgress()
},
onTaskCompleted: (videos) => {
onTaskCompleted: (videos, taskStatus) => {
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
},
@@ -293,8 +294,9 @@ export function useGenerationPolling({
}
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
onTaskCompleted: (videos) => {
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
onTaskCompleted: (videos, taskStatus) => {
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
message.success(`视频 ${variantIndex + 1} 重试成功`)
},
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
}
export function useBatchCovers({
selectedTemplate: _selectedTemplate,
selectedTemplate,
generatedVideos,
titles,
titleStyle,
@@ -93,7 +93,12 @@ export function useBatchCovers({
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" ||
v.status === "awaiting_cover" ||
v.status === "awaiting_cover",
)
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
@@ -102,7 +107,7 @@ export function useBatchCovers({
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover("default", {
const response = await generateCover(selectedTemplate || "default", {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
@@ -166,7 +171,7 @@ export function useBatchCovers({
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
@@ -203,7 +208,10 @@ export function useBatchCovers({
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
)
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
@@ -22,6 +22,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
const [currentTaskId, setCurrentTaskId] = useState<string>("")
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
@@ -58,7 +60,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
setBatchTasks((prev) =>
(prev || []).map((t) =>
t.status === "completed" && t.videos.length === 0
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
? {
...t,
videos: (videos as GeneratedVideo[]).filter(
@@ -83,7 +85,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
if (batchTasks.length === 0) return
const byVariant = new Map<number, GeneratedVideo>()
batchTasks.forEach((t) => {
if (t.status === "completed" && t.videos && t.videos.length > 0) {
if (
t.status === "completed" ||
(t.status === "awaiting_cover" && t.videos && t.videos.length > 0)
) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
})
@@ -117,6 +122,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerated(false)
setGenerateError(null)
setBatchTasks([])
setGeneratedVideos([])
setCurrentTaskId("")
clearTimer()
try {
@@ -338,8 +345,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
if (taskIds.length > 1) {
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
setCurrentTaskId("")
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
} else {
setCurrentTaskId(taskIds[0])
startPolling(taskIds[0])
}
} catch (err) {
@@ -414,6 +423,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
generated,
generateError,
generatedVideos,
currentTaskId,
generate,
retry,
retryBatchTask,
@@ -48,7 +48,11 @@ export function useStep6Cover({
const [templatesError, setTemplatesError] = useState<string | null>(null)
/** 最终成片:取第一个已完成视频 */
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
const finalVideo =
generatedVideos.find(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
) || generatedVideos[0]
/** 从后端加载封面模板列表 */
const loadTemplates = useCallback(async () => {
@@ -171,7 +175,18 @@ export function useStep6Cover({
}, [])
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
setEditingTemplate(tpl)
// 系统模板不可修改:复制为新模板草稿,走"另存为"流程
if (tpl.is_system) {
setEditingTemplate({
...tpl,
id: "",
name: tpl.name + " 副本",
is_system: false,
created_at: "",
})
} else {
setEditingTemplate(tpl)
}
setShowCoverEditor(true)
}, [])
@@ -179,20 +194,25 @@ export function useStep6Cover({
const handleSaveTemplate = useCallback(
async (tpl: CoverTemplate) => {
try {
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
// 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新
const isSystem = coverTemplates.find((t) => t.id === tpl.id)?.is_system === true
const shouldCreate = !tpl.id || isSystem
if (shouldCreate) {
const created = await createCoverTemplate({
name: tpl.name || "我的封面模板",
config: tpl.config,
})
setCoverTemplates((prev) => [...prev, created])
setSelectedTemplateId(created.id || tpl.id)
} else {
const updated = await updateCoverTemplate(tpl.id, {
name: tpl.name,
config: tpl.config,
})
setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
} else {
const created = await createCoverTemplate({
name: tpl.name,
config: tpl.config,
})
setCoverTemplates((prev) => [...prev, created])
}
setShowCoverEditor(false)
setEditingTemplate(null)
} catch (err) {
console.error("[Step6] 保存模板失败:", err)
}
@@ -4,7 +4,7 @@
*
* - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal),
* 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。
* - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。
* - 步骤2(选择素材):直接进入步骤3,数组长度对齐由 onBeforeEnterStep3 保证。
* - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
* 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。
* - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。
@@ -23,10 +23,10 @@ export interface UseStepNavigationOptions {
titleSettings: TitleSettings
/** 是否已完成视频生成(步骤4全部渲染完成后才能进入封面) */
generated: boolean
/** 点素材下一步时弹出数量选择弹窗 */
onOpenCountModal: () => void
/** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */
onOpenStep1Modal: () => void
/** 进入步骤3前自动对齐数组(previewTitles/voiceLibraryIds/previewCovers/selectedVariantIds)长度到 previewCount */
onBeforeEnterStep3?: () => void
}
export interface UseStepNavigationReturn {
@@ -42,8 +42,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
selectedMaterials,
smartSelectedIds,
generated,
onOpenCountModal,
onOpenStep1Modal,
onBeforeEnterStep3,
} = options
const goNext = () => {
@@ -62,8 +62,9 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
// 弹数量选择弹窗
onOpenCountModal()
// 直接进入步骤3(生成数量在 Step1 已设置);对齐数组长度
onBeforeEnterStep3?.()
setCurrentStep(3)
return
}
// 步骤4(确认生成):全部渲染完成后才能下一步进封面
+236 -8
View File
@@ -32,6 +32,241 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
thumbnail_url: "",
}
/** 文字方向 */
export type TextDirection = "horizontal" | "vertical"
/** 文字背景形状 */
export type TextBgShape = "rectangle" | "polygon"
/** 描边样式 */
export type StrokeStyle = "solid" | "dashed"
/** 阴影层 */
export interface ShadowLayer {
color: string
offsetX: number
offsetY: number
blur: number
}
/** 文字位置 */
export interface TextPosition {
x: number
y: number
}
/** 文字背景配置 */
export interface TextBackground {
enabled: boolean
color: string
opacity: number
shape: TextBgShape
width: number
height: number
posX: number
posY: number
rotation: number
}
/** 文字样式配置(主标题/副标题共用) */
export interface TextStyleConfig {
text: string
fontFamily: string
fontSize: number
fontWeight: number
direction: TextDirection
charsPerLine: number
letterSpacing: number
lineHeight: number
color: string
strokeColor: string
strokeWidth: number
shadows: ShadowLayer[]
traditionalShadow: boolean
position: TextPosition
rotation: number
background: TextBackground
}
/** 编辑器完整配置 */
export interface CoverEditorConfig {
// 基础设置
blurEnabled: boolean
blurAmount: number
personStrokeEnabled: boolean
personStrokeStyle: StrokeStyle
personStrokeColor: string
personStrokeWidth: number
autoSplitEnabled: boolean
titleMaxChars: number
subtitleMaxChars: number
// 人像设置
portraitEnabled: boolean
portraitSize: number
portraitPosition: TextPosition
portraitImage?: string
// 背景设置
backgroundEnabled: boolean
backgroundSize: number
backgroundPosition: TextPosition
backgroundImage?: string
backgroundColor?: string
// 主标题
title: TextStyleConfig
// 副标题
subtitle: TextStyleConfig
// 蒙版
maskEnabled: boolean
maskImage: string
maskSize: number
maskPosition: TextPosition
maskColor: string
maskOpacity: number
maskShape: string
}
/** 默认主标题配置 */
export const DEFAULT_TITLE_CONFIG: TextStyleConfig = {
text: "主标题文字",
fontFamily: "思源黑体",
fontSize: 120,
fontWeight: 700,
direction: "horizontal",
charsPerLine: 10,
letterSpacing: 24,
lineHeight: 144,
color: "#FFD700",
strokeColor: "#000000",
strokeWidth: 3,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 30 },
rotation: 0,
background: {
enabled: false,
color: "#FFFFFF",
opacity: 25,
shape: "polygon",
width: 30,
height: 10,
posX: 50,
posY: 50,
rotation: 0,
},
}
/** 默认副标题配置 */
export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = {
text: "副标题文字",
fontFamily: "思源黑体",
fontSize: 82,
fontWeight: 500,
direction: "horizontal",
charsPerLine: 17,
letterSpacing: 23,
lineHeight: 72,
color: "#FFFFFF",
strokeColor: "#000000",
strokeWidth: 1,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 70 },
rotation: 0,
background: {
enabled: true,
color: "#000000",
opacity: 70,
shape: "rectangle",
width: 100,
height: 20,
posX: 50,
posY: 80,
rotation: 0,
},
}
/** 默认编辑器配置 */
export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
blurEnabled: false,
blurAmount: 10,
personStrokeEnabled: false,
personStrokeStyle: "solid",
personStrokeColor: "#FFFFFF",
personStrokeWidth: 8,
autoSplitEnabled: false,
titleMaxChars: 4,
subtitleMaxChars: 10,
portraitEnabled: false,
portraitSize: 50,
portraitPosition: { x: 50, y: 70 },
backgroundEnabled: true,
backgroundSize: 100,
backgroundPosition: { x: 50, y: 50 },
title: DEFAULT_TITLE_CONFIG,
subtitle: DEFAULT_SUBTITLE_CONFIG,
maskEnabled: false,
maskImage: "",
maskSize: 100,
maskPosition: { x: 50, y: 50 },
maskColor: "#000000",
maskOpacity: 40,
maskShape: "矩形",
}
/** 预置字体 */
export const PRESET_FONTS = [
{ name: "思源黑体", family: "'Noto Sans SC', sans-serif" },
{ name: "斗鱼追光体2.0", family: "'DouYu ZhuangGuangTi', sans-serif" },
{ name: "抖音美好体", family: "'DouYin MeiHaoTi', sans-serif" },
]
/** 系统字体 */
export const SYSTEM_FONTS = [
{ name: "Arial", family: "Arial, sans-serif" },
{ name: "Helvetica", family: "Helvetica, sans-serif" },
{ name: "Times New Roman", family: "'Times New Roman', serif" },
{ name: "Georgia", family: "Georgia, serif" },
{ name: "Verdana", family: "Verdana, sans-serif" },
{ name: "Tahoma", family: "Tahoma, sans-serif" },
{ name: "Impact", family: "Impact, sans-serif" },
{ name: "Comic Sans MS", family: "'Comic Sans MS', cursive" },
{ name: "Courier New", family: "'Courier New', monospace" },
{ name: "微软雅黑", family: "'Microsoft YaHei', sans-serif" },
{ name: "宋体", family: "SimSun, serif" },
{ name: "黑体", family: "SimHei, sans-serif" },
{ name: "楷体", family: "KaiTi, serif" },
{ name: "仿宋", family: "FangSong, serif" },
{ name: "Trebuchet MS", family: "'Trebuchet MS', sans-serif" },
{ name: "Lucida Console", family: "'Lucida Console', monospace" },
{ name: "Palatino", family: "Palatino, serif" },
{ name: "Garamond", family: "Garamond, serif" },
{ name: "Bookman", family: "Bookman, serif" },
{ name: "Avant Garde", family: "'Avant Garde', sans-serif" },
{ name: "Calibri", family: "Calibri, sans-serif" },
{ name: "Cambria", family: "Cambria, serif" },
{ name: "Candara", family: "Candara, sans-serif" },
{ name: "Consolas", family: "Consolas, monospace" },
{ name: "Constantia", family: "Constantia, serif" },
{ name: "Corbel", family: "Corbel, sans-serif" },
{ name: "Franklin Gothic", family: "'Franklin Gothic', sans-serif" },
{ name: "Gill Sans", family: "'Gill Sans', sans-serif" },
{ name: "Optima", family: "Optima, sans-serif" },
{ name: "Futura", family: "Futura, sans-serif" },
{ name: "Rockwell", family: "Rockwell, serif" },
]
/** 所有字体列表 */
export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS]
/** 封面模板 */
export interface CoverTemplate {
id: string
@@ -39,12 +274,5 @@ export interface CoverTemplate {
thumbnail_url: string
is_system: boolean
created_at: string
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
config?: CoverEditorConfig
}
@@ -20,7 +20,6 @@ import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/Step3VoiceWithMode"
import "@/pages/generate/components/BatchGenerationGrid"
import "@/pages/generate/components/PreviewCountModal"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/components/voice/VoiceRecommendSection"
import "@/pages/generate/components/voice/VoiceChoiceCard"
+26
View File
@@ -345,6 +345,32 @@ class VideoFingerprint:
],
}
@classmethod
def from_dict(cls, data: dict) -> "VideoFingerprint":
"""从 to_dict() 序列化结果重建 VideoFingerprint(供 finalize 复用 worker 预计算指纹)。"""
chunks_raw = data.get("chunks") or []
chunks: list[FingerprintChunk] = []
for c in chunks_raw:
chunks.append(
FingerprintChunk(
start_time_ms=int(c.get("start_time_ms", 0)),
end_time_ms=int(c.get("end_time_ms", 0)),
phash_binary=str(c.get("phash_binary", "")),
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
frame_count=int(c.get("frame_count", 0)),
)
)
resolution_raw = data.get("resolution") or [1280, 720]
return cls(
md5=str(data.get("md5", "")),
keyframe_phashes=list(data.get("keyframe_phashes") or []),
color_histograms=[[float(v) for v in h] for h in (data.get("color_histograms") or [])],
duration=float(data.get("duration") or 0.0),
resolution=(int(resolution_raw[0]), int(resolution_raw[1])) if len(resolution_raw) >= 2 else (1280, 720),
chunks=chunks,
)
def to_chunk_models(self, video_id: str, project_id: str, user_id: str = "") -> list[VideoFingerprintChunkModel]:
"""将分片数据转为 SQLAlchemy Model 列表,用于批量写入 video_fingerprint_chunks 表。"""
models = []
+239 -117
View File
@@ -1,10 +1,15 @@
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
"""查重辅助函数 — 渲染阶段指纹/查重预计算 + 兼容旧入库函数。
供 generate_video 共同复用,
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。
#2024: Worker 渲染+上传完成后**不直接创建 GeneratedVideo 成品记录**,改为:
1. ``compute_render_fingerprint_and_dedup``: 从本地视频计算指纹+查重(历史+批次),
返回可序列化 dict(含 fingerprint_chunks),由 worker 写入
``GenerationTask.extra_meta["rendered_output"]``;
2. ``create_video_record_and_dedup``: 保留兼容——当传入 ``video_path`` 时会从本地视频
计算指纹+查重并直接创建 GeneratedVideo 记录(供测试/旧路径使用);
当仅传 ``pre_dedup_result`` 时复用预计算结果,不再访问本地视频。
v2: 两阶段持久化 — 先计算所有查重数据,再一次性 commit,
避免中间异常导致 duplicate_rate 等字段缺失。
finalize 入口走 ``packages/application/generated_video_finalize.py`` 的
``finalize_generated_video``,不依赖本模块中数据库以外的 worker-only 逻辑。
"""
from __future__ import annotations
@@ -17,6 +22,149 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _safe_parse_fps(raw) -> float:
if raw is None:
return 25.0
if isinstance(raw, (int, float)):
return float(raw)
s = str(raw).strip()
if "/" in s:
try:
num, den = s.split("/", 1)
return float(num) / float(den) if float(den) != 0 else 25.0
except (ValueError, ZeroDivisionError):
pass
try:
return float(s)
except (ValueError, TypeError):
return 25.0
def _compute_from_local(
*,
video_path: str,
generation_task_id: str,
project_id: str,
user_id: str,
batch_id: str,
session: Session,
) -> dict:
"""从本地视频计算指纹+查重,返回可序列化结果 dict(不创建 DB 记录)。"""
from video_processing.dedup import VideoDeduplicator
from video_processing.ffmpeg_utils import probe_video_info
result: dict = {
"fingerprint_dict": None,
"fingerprint_chunks": None,
"duration": 0.0,
"width": 1280,
"height": 720,
"fps": 25.0,
"is_duplicate": False,
"duplicate_of": None,
"duplicate_rate": None,
"match_count": None,
"visual_similarity": None,
"video_fingerprint_md5": "",
"batch_similarity": None,
}
try:
info = probe_video_info(video_path)
result["duration"] = float(info.get("duration") or 0.0)
result["width"] = int(info.get("width") or 1280)
result["height"] = int(info.get("height") or 720)
result["fps"] = _safe_parse_fps(info.get("fps"))
except Exception as info_err:
logger.warning("probe_video_info failed for task %s: %s", generation_task_id, info_err)
try:
deduplicator = VideoDeduplicator()
fingerprint = deduplicator.compute_fingerprint(video_path)
fp_dict = fingerprint.to_dict()
result["fingerprint_dict"] = fp_dict
result["video_fingerprint_md5"] = fingerprint.md5 or ""
result["fingerprint_chunks"] = [
{
"start_time_ms": c.start_time_ms,
"end_time_ms": c.end_time_ms,
"phash_binary": c.phash_binary,
"color_histogram": [float(v) for v in c.color_histogram],
"frame_count": c.frame_count,
}
for c in fingerprint.chunks
]
# 用 placeholder_id 占位(还没有真正的 video_id,不影响查重逻辑——
# 因为查重排除的是 GeneratedVideo 表中的记录)
placeholder_id = f"pre-{generation_task_id}"
duration_sec = fingerprint.duration if fingerprint.duration else 0
duplicate_result = deduplicator.check_duplicate(
fingerprint,
project_id,
session,
scope="user",
user_id=user_id,
duration_sec=duration_sec,
exclude_video_id=placeholder_id,
)
batch_sim: float | None = None
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, placeholder_id, session)
if duplicate_result:
batch_sim = float(duplicate_result.get("similarity", 0.0))
result["batch_similarity"] = batch_sim
if duplicate_result:
result["is_duplicate"] = True
result["duplicate_of"] = duplicate_result["duplicate_of"]
else:
result["is_duplicate"] = False
try:
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id,
placeholder_id,
session,
scope="user",
user_id=user_id,
)
result["duplicate_rate"] = rate_result.get("duplicate_rate")
result["match_count"] = rate_result.get("match_count")
result["visual_similarity"] = rate_result.get("visual_similarity")
except Exception as rate_err:
logger.warning("compute_duplicate_rate failed for task %s: %s", generation_task_id, rate_err)
except Exception as fp_err:
logger.warning("Fingerprint compute failed for task %s: %s", generation_task_id, fp_err)
return result
def compute_render_fingerprint_and_dedup(
*,
video_path: str,
generation_task_id: str,
project_id: str,
user_id: str,
batch_id: str,
mode: str,
session: Session,
) -> dict:
"""渲染+上传完成后的预计算:计算指纹+历史/批次查重,返回可序列化 dict。
**不创建 GeneratedVideo 记录**。结果由调用方写入 extra_meta["rendered_output"],
finalize 时复用。mode 参数保留签名一致性(查重结果中不直接使用)。
"""
_ = mode # 保留在签名里便于调用方对齐;查重结果不含 mode
return _compute_from_local(
video_path=video_path,
generation_task_id=generation_task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
session=session,
)
def create_video_record_and_dedup(
*,
generation_task_id: str,
@@ -25,8 +173,8 @@ def create_video_record_and_dedup(
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
duration: float | None = None,
video_path: str | None,
mode: str,
session: Session,
width: int = 1280,
@@ -34,30 +182,55 @@ def create_video_record_and_dedup(
fps: float = 25.0,
name: str = "",
thumbnail_url: str = "",
pre_fingerprint_dict: dict | None = None,
pre_fingerprint_chunks: list[dict] | None = None,
pre_dedup_result: dict | None = None,
) -> dict:
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
"""创建 GeneratedVideo 记录 + 可选查重。
采用两阶段持久化:先计算所有指纹/查重数据(内存),
再一次性写入数据库并 commit。若指纹计算失败,
视频记录仍会创建(无查重数据),但保证不会出现"写了记录却没 commit"的中间态。
两种用法:
- 传入 ``video_path``(非 None):从本地视频计算指纹+查重,直接创建记录(旧路径/测试)。
- 仅传入 ``pre_*``:复用 worker 预计算结果,不访问本地视频(finalize 用)。
Returns:
创建的视频记录数量(1 表示成功,0 表示失败)
{"video_id", "video_count", "is_duplicate", "batch_similarity", "duplicate_of"}
"""
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
from packages.domain.generated_video import GeneratedVideo
try:
video_id = uuid4().hex
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
# ── Phase 1: 构建视频记录(内存,不 commit) ────────────────
# 决定查重/元信息来源
if video_path:
pre = _compute_from_local(
video_path=video_path,
generation_task_id=generation_task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
session=session,
)
else:
pre = dict(pre_dedup_result or {})
pre.setdefault("fingerprint_dict", pre_fingerprint_dict)
pre.setdefault("fingerprint_chunks", pre_fingerprint_chunks)
pre.setdefault("is_duplicate", False)
pre.setdefault("duplicate_of", None)
pre.setdefault("duplicate_rate", None)
pre.setdefault("match_count", None)
pre.setdefault("visual_similarity", None)
pre.setdefault("batch_similarity", None)
used_duration = float(duration if duration is not None else pre.get("duration", 0.0))
used_width = int(pre.get("width", width) or width)
used_height = int(pre.get("height", height) or height)
used_fps = float(pre.get("fps", fps) or fps)
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
@@ -66,117 +239,66 @@ def create_video_record_and_dedup(
name=video_name,
file_url=file_url,
file_size=file_size,
duration=duration,
width=width,
height=height,
fps=fps,
duration=used_duration,
width=used_width,
height=used_height,
fps=used_fps,
status="completed",
generation_params={"mode": mode},
thumbnail_url=thumbnail_url or None,
video_fingerprint=pre.get("fingerprint_dict"),
is_duplicate=bool(pre.get("is_duplicate", False)),
duplicate_of=pre.get("duplicate_of"),
duplicate_rate=pre.get("duplicate_rate"),
match_count=pre.get("match_count"),
visual_similarity=pre.get("visual_similarity"),
)
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
deduplicator = VideoDeduplicator()
fingerprint = None
batch_similarity: float | None = None
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
if fingerprint is not None:
generated_video.video_fingerprint = fingerprint.to_dict()
# 写入分片指纹表(失败不阻塞)
# 写分片指纹表
chunks = pre.get("fingerprint_chunks")
if chunks:
try:
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
chunk_models = [
VideoFingerprintChunkModel(
id=uuid4().hex,
video_id=video_id,
project_id=project_id,
user_id=user_id,
start_time_ms=int(c.get("start_time_ms", 0)),
end_time_ms=int(c.get("end_time_ms", 0)),
phash_binary=str(c.get("phash_binary", "")),
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
frame_count=int(c.get("frame_count", 0)),
)
for c in chunks
if isinstance(c, dict)
]
if chunk_models:
# 幂等:先清理旧分片
session.query(VideoFingerprintChunkModel).filter(
VideoFingerprintChunkModel.video_id == video_id
).delete(synchronize_session=False)
session.bulk_save_objects(chunk_models)
except Exception as chunk_err:
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
# Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效
duration_sec = fingerprint.duration if fingerprint.duration else 0
duplicate_result = deduplicator.check_duplicate(
fingerprint,
project_id,
session,
scope="user",
user_id=user_id,
duration_sec=duration_sec,
exclude_video_id=video_id,
)
# (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"]
logger.info(
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
video_id,
duplicate_result["duplicate_of"],
duplicate_result["reason"],
duplicate_result["similarity"],
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
# 计算重复率百分比(跨项目全局)
try:
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id,
video_id,
session,
scope="user",
user_id=user_id,
)
generated_video.duplicate_rate = rate_result["duplicate_rate"]
generated_video.match_count = rate_result["match_count"]
generated_video.visual_similarity = rate_result["visual_similarity"]
logger.info(
"Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)",
video_id,
rate_result["duplicate_rate"],
rate_result["visual_similarity"],
rate_result["match_count"],
)
except Exception as rate_err:
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
generated_video.duplicate_rate = None
# ── Phase 3: 一次性持久化 ─────────────────────────────────
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
if thumbnail_url:
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80])
repo = SQLAlchemyGeneratedVideoRepository(session)
repo.create(generated_video)
session.commit()
logger.info(
"GeneratedVideo record created: %s (task=%s, dup=%s, rate=%s)",
video_id,
generation_task_id,
generated_video.is_duplicate,
generated_video.duplicate_rate,
)
return {
"video_id": video_id,
"video_count": 1,
"is_duplicate": bool(generated_video.is_duplicate),
"batch_similarity": batch_similarity,
"duplicate_of": generated_video.duplicate_of,
"is_duplicate": bool(pre.get("is_duplicate", False)),
"batch_similarity": pre.get("batch_similarity"),
"duplicate_of": pre.get("duplicate_of"),
}
except Exception as e:
logger.error(
"Failed to create video record / dedup for task %s: %s",
generation_task_id,
e,
)
logger.error("Failed to create video record for task %s: %s", generation_task_id, e)
session.rollback()
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
return {
"video_id": "",
"video_count": 0,
"is_duplicate": False,
"batch_similarity": None,
"duplicate_of": None,
}
@@ -2384,6 +2384,9 @@ class UnifiedRenderService:
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
def _get_visual_perturbation(self) -> dict:
# #2034:dedup_enabled=False 时跳过视觉/像素扰动(与 edge_crop、micro_transform 一致)
if not self._dedup_enabled():
return {}
# 读取当前 plan 的视觉扰动参数(plan.config.visual_perturbation)
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
if not perturbation:
+1
View File
@@ -31,6 +31,7 @@ celery_app.conf.imports = (
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
# "Received unregistered task of type 'worker.tag_atom_clip'"
"worker_app.tasks.atom_clip_tagging",
"worker_app.tasks.asset_quality_scoring_task",
"worker_app.tasks.backfill_atom_clip_tags",
"worker_app.tasks.classification",
"worker_app.tasks.generation",
@@ -445,22 +445,3 @@ def classify_asset_real(video_path: str) -> tuple[str, float]:
except Exception as e:
logger.warning(f"Classification failed, using fallback: {e}")
return AssetClassification.OTHER.value, 0.3
def calculate_quality_score_real(video_path: str) -> float:
"""
质量评分入口函数
Args:
video_path: 视频文件路径
Returns:
质量评分 (0-100)
"""
try:
analyzer = AssetAnalyzer(video_path)
result = analyzer.calculate_quality_score()
return result.total
except Exception as e:
logger.warning(f"Quality scoring failed, using fallback: {e}")
return 50.0
@@ -0,0 +1,143 @@
"""素材质量评分 Celery 任务 — #2035.
视频素材 READY 入库后异步触发:下载视频到临时文件,运行 FFmpeg+NumPy 质量分析,
将 0-100 总分写入 assets.quality_score 字段。同时复用已下载的视频,调用 AssetAnalyzer
完成 9 类素材分类(写入 asset.metadata.classification / classification_confidence),
供 smart_match 选片打分使用。任一环节失败均不阻断主流程(质量分兜底 50,分类降级 "other")。
任务名:worker.calculate_asset_quality
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.shared.storage import get_shared_storage_service
logger = get_task_logger(__name__)
@celery_app.task(name="worker.calculate_asset_quality", bind=True, max_retries=1, default_retry_delay=15)
def calculate_asset_quality_task(self, asset_id: str) -> dict:
"""为单个视频素材计算质量评分并写回 assets.quality_score。
流程:
1. 下载视频到临时文件;
2. 用 AssetAnalyzer(FFmpeg+NumPy) 提取分辨率/帧率/码率/清晰度/稳定性 5 维分数;
3. 写回 assets.quality_score;
4. 复用同一临时文件,调用 AssetAnalyzer.classify() 做 9 类素材分类,
结果写入 asset.metadata.classification / classification_confidence;
如已有分类结果则幂等跳过(避免重复计算)。
失败/非视频/无文件等情况均静默降级,返回 status=skipped/failed 不抛异常。
"""
db = SessionLocal()
tmp_dir = tempfile.mkdtemp(prefix="quality_score_")
try:
asset_repo = SQLAlchemyAssetRepository(db)
asset = asset_repo.find_by_id(asset_id)
if asset is None:
return {"status": "skipped", "reason": "asset not found", "asset_id": asset_id}
if not (getattr(asset, "mime_type", "") or "").startswith("video/"):
return {"status": "skipped", "reason": "not a video", "asset_id": asset_id}
# 已有质量分则幂等跳过(重新计算需显式置空)
if getattr(asset, "quality_score", None) is not None:
return {"status": "skipped", "reason": "already scored", "asset_id": asset_id}
storage = get_shared_storage_service()
storage_key = getattr(asset, "storage_key", "") or ""
if not storage_key:
return {"status": "skipped", "reason": "no storage_key", "asset_id": asset_id}
# 下载到临时文件
safe_suffix = ".mp4"
local_path = Path(tmp_dir) / f"asset_{asset_id[:8]}{safe_suffix}"
ok = storage.download_asset(storage_key, local_path)
if not ok or not local_path.exists() or local_path.stat().st_size == 0:
return {"status": "failed", "reason": "download failed", "asset_id": asset_id}
# 调用 AssetAnalyzer
from worker_app.tasks.asset_analyzer import AssetAnalyzer
try:
analyzer = AssetAnalyzer(str(local_path), temp_dir=tmp_dir)
result = analyzer.calculate_quality_score()
total = float(result.total) if result and 0 <= result.total <= 100 else 50.0
except Exception as analyze_err: # noqa: BLE001
logger.warning("[quality_score] 分析失败,使用默认50分: asset=%s err=%s", asset_id, analyze_err)
total = 50.0
# 写回数据库(质量分)
asset.quality_score = total
# #2035:自动触发 9 类分类(复用已下载的临时文件,避免重复下载)
existing_meta = dict(asset.metadata or {})
existing_classification = existing_meta.get("classification")
classification = None
confidence = None
if not existing_classification or existing_classification == "other":
try:
from worker_app.tasks.asset_analyzer import AssetAnalyzer as _AA
# 重新构造analyzer可能会重复抽帧,但classify()会复用临时帧
_analyzer = _AA(str(local_path), temp_dir=tmp_dir)
_cls_result = _analyzer.classify()
classification = getattr(_cls_result, "category", None) or "other"
confidence = float(getattr(_cls_result, "confidence", 0.0) or 0.0)
if confidence < 0:
confidence = 0.0
if confidence > 1:
confidence = 1.0
existing_meta["classification"] = classification
existing_meta["classification_confidence"] = confidence
asset.classification_status = "completed"
asset.metadata = existing_meta
logger.info(
"[quality_score] asset=%s 自动分类完成: category=%s confidence=%.2f",
asset_id,
classification,
confidence,
)
except Exception as cls_err: # noqa: BLE001
logger.warning(
"[quality_score] asset=%s 自动分类失败(不影响质量分): %s",
asset_id,
cls_err,
)
asset_repo.update(asset)
db.commit()
logger.info(
"[quality_score] asset=%s score=%.1f classification=%s",
asset_id,
total,
classification or existing_classification,
)
return {
"status": "completed",
"asset_id": asset_id,
"quality_score": total,
"classification": classification or existing_classification or "other",
}
except Exception as exc: # noqa: BLE001
db.rollback()
logger.exception("[quality_score] asset=%s 失败: %s", asset_id, exc)
if self.request.retries < self.max_retries:
raise self.retry(exc=exc) from None
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
finally:
db.close()
# 清理临时文件
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
@@ -1,7 +1,8 @@
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
为单个 atom_clip 调用视觉 AI 生成结构化标签,并更新到 ai_tags 字段。
失败不阻断流程(降级为仅继承素材标签)。
为单个 atom_clip 调用视觉 AI 生成结构化标签(含 caption),再调用
豆包 embedding 接口为 caption 生成向量,一并写入数据库。
失败不阻断流程(降级为仅继承素材标签 / caption 留空 / embedding 留空)。
任务名:worker.tag_atom_clip
"""
@@ -26,16 +27,16 @@ logger = get_task_logger(__name__)
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
"""为单个原子片段生成 AI 标签.
"""为单个原子片段生成 AI 标签 + caption + embedding.
Args:
atom_clip_id: 原子片段 ID。
force: True 时允许覆盖只有 inherited_tags 的降级记录
(视觉 API 曾失败写入的占位标签,#1970)。
已有完整标签(含 has_text)始终跳过,保证幂等。
已有完整标签且有 caption 始终跳过,保证幂等。
Returns:
任务结果 dict:status / clip_id / ai_tags(部分字段)。
任务结果 dict:status / clip_id / has_ai_tags / caption / embedding_dim。
"""
db = SessionLocal()
try:
@@ -46,11 +47,27 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
if clip is None:
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
# 已有完整标签则跳过(幂等);force 仅放行缺失 has_text 的降级记录
if clip.ai_tags is not None:
has_real_tags = isinstance(clip.ai_tags, dict) and "has_text" in clip.ai_tags
if has_real_tags or not force:
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
# 幂等:已有任意 ai_tags(含降级占位)则按 force 策略跳过;person_count/text_content 为附加字段不单独触发重跑
# - 无 force:只要 ai_tags 非 None 就跳过(与旧逻辑一致)
# - force=True 且 ai_tags 是完整标签(含 has_text)且 caption 已存在才跳过
existing_tags = clip.ai_tags
has_real_tags = isinstance(existing_tags, dict) and "has_text" in existing_tags
bool(getattr(clip, "caption", None))
if existing_tags is not None:
if not force:
return {
"status": "skipped",
"reason": "already tagged",
"clip_id": atom_clip_id,
}
# force=True:有完整标签(has_text)就跳过;caption 是 #2035 新增的
# 字段,对已有完整标签的历史数据不强制重跑
if has_real_tags:
return {
"status": "skipped",
"reason": "already tagged",
"clip_id": atom_clip_id,
}
# 获取素材信息
asset = asset_repo.find_by_id(clip.asset_id)
@@ -65,7 +82,7 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
doubao_client = get_doubao_client()
mediakit_client = get_mediakit_client()
# 调用 tagger
# 调用 tagger(视觉 API → ai_tags + caption)
ai_tags = tag_atom_clip(
clip=clip,
video_url=video_url,
@@ -74,18 +91,54 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
storage=storage,
)
# 更新数据库
# 先写入 AI 标签(含 caption 字段在 ai_tags 字典里)
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
# 提取 caption 并生成 embedding(失败降级,不阻断主流程)
caption = (ai_tags or {}).get("caption", "") or ""
embedding = None
try:
if caption.strip() and doubao_client.is_available:
embedding = doubao_client.embed_text(caption)
except Exception as exc: # noqa: BLE001
logger.warning(
"[atom_clip_tagging] clip_id=%s embedding 生成失败,降级为空: %s",
atom_clip_id,
exc,
)
embedding = None
# 写入 caption + embedding(caption 冗余写一次到独立列,便于查询)
try:
atom_repo.update_caption_embedding(atom_clip_id, caption, embedding)
except Exception as exc: # noqa: BLE001
logger.warning(
"[atom_clip_tagging] clip_id=%s caption/embedding 写入失败: %s",
atom_clip_id,
exc,
)
person_count = (ai_tags or {}).get("person_count", 0)
(ai_tags or {}).get("text_content", "") or ""
logger.info(
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
"[atom_clip_tagging] clip_id=%s ai_tags=%s caption=%r person_count=%s has_text=%s embedding_dim=%s",
atom_clip_id,
{k: v for k, v in ai_tags.items() if k != "inherited_tags"},
{k: v for k, v in ai_tags.items() if k not in ("inherited_tags", "caption", "text_content")},
caption,
person_count,
bool((ai_tags or {}).get("has_text")),
len(embedding) if embedding else 0,
)
return {
"status": "completed",
"clip_id": atom_clip_id,
"has_ai_tags": any(v for k, v in ai_tags.items() if k != "inherited_tags" and v),
"has_ai_tags": any(
v for k, v in ai_tags.items() if k not in ("inherited_tags", "caption", "text_content") and v
),
"caption": caption,
"person_count": (ai_tags or {}).get("person_count", 0),
"text_content": (ai_tags or {}).get("text_content", "") or "",
"embedding_dim": len(embedding) if embedding else 0,
}
except Exception as exc:
db.rollback()
@@ -19,6 +19,7 @@ from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.atom_clip_service import compute_atom_clips
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
from packages.shared.mediakit_client import get_mediakit_client
logger = get_task_logger(__name__)
@@ -56,6 +57,35 @@ def generate_atom_clips(asset_id: str) -> dict:
}
scene_points = extract_scene_points_from_metadata(asset.metadata)
# #2035:metadata 中没有 scene_change_points 时,按需调用 MediaKit 检测
# (templates_editor 路由会主动写 metadata,ingest 流程此前未触发检测导致切点无法对齐)
if not scene_points:
try:
mk = get_mediakit_client()
video_url = getattr(asset, "file_url", "") or ""
if mk.is_available and video_url:
timestamps = mk.detect_scene_changes(video_url)
if timestamps:
scene_points = timestamps
# 持久化到 metadata,避免下次重复检测
new_meta = dict(asset.metadata or {})
new_meta["scene_change_points"] = list(timestamps)
asset.metadata = new_meta
asset_repo.update(asset)
db.commit()
logger.info(
"[atom_clips] asset_id=%s 自动检测到 %d 个场景切换点并写回metadata",
asset_id,
len(timestamps),
)
except Exception as detect_err: # noqa: BLE001
logger.warning(
"[atom_clips] asset_id=%s scene_change自动检测失败,降级为均匀切片: %s",
asset_id,
detect_err,
)
db.rollback() # 回滚metadata写失败,不影响后续切片
# P1 阶段继承素材的标签 ID;片段级语义标签是 P2 功能
tags = list(getattr(asset, "tag_ids", []) or [])
+87 -74
View File
@@ -17,7 +17,6 @@ import logging
import time
from pathlib import Path
from video_processing.ffmpeg_utils import probe_duration
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
@@ -142,7 +141,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.dedup_helpers import compute_render_fingerprint_and_dedup
from video_processing.oss_helpers import (
download_asset,
get_signed_download_url,
@@ -555,7 +554,7 @@ def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict)
return None
def _record_video_and_dedup(
def _precompute_render_metadata(
*,
task_id: str,
project_id: str,
@@ -568,28 +567,48 @@ def _record_video_and_dedup(
video_name: str = "",
thumbnail_url: str = "",
) -> dict:
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
duration = probe_duration(Path(video_path))
dedup_session = SessionLocal()
"""渲染+上传完成后的预处理:计算指纹/查重(不落 GeneratedVideo 库)。
#2024: 视频生成后不再自动入成品库。本函数计算视频元信息、指纹、历史+批次查重,
结果以 dict 返回,由调用方写入 GenerationTask.extra_meta["rendered_output"],
等用户 Step5 调 finalize 时复用,避免 finalize 时从 OSS 下载视频重算。
"""
pre_session = SessionLocal()
try:
result = create_video_record_and_dedup(
fp_result = compute_render_fingerprint_and_dedup(
video_path=video_path,
generation_task_id=task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=video_path,
mode=editing_mode.value,
session=dedup_session,
name=video_name,
thumbnail_url=thumbnail_url,
session=pre_session,
)
finally:
dedup_session.close()
result["duration"] = duration
return result
pre_session.close()
return {
"file_url": file_url,
"file_size": file_size,
"duration": fp_result.get("duration", 0.0),
"width": fp_result.get("width", 1280),
"height": fp_result.get("height", 720),
"fps": fp_result.get("fps", 25.0),
"name": video_name,
"thumbnail_url": thumbnail_url,
"mode": editing_mode.value,
"batch_id": batch_id,
"project_id": project_id,
"user_id": user_id,
# 查重结果(finalize 时直接写入 GeneratedVideo 字段,无需重算)
"fingerprint_dict": fp_result.get("fingerprint_dict"),
"fingerprint_chunks": fp_result.get("fingerprint_chunks"),
"is_duplicate": bool(fp_result.get("is_duplicate", False)),
"duplicate_of": fp_result.get("duplicate_of"),
"duplicate_rate": fp_result.get("duplicate_rate"),
"match_count": fp_result.get("match_count"),
"visual_similarity": fp_result.get("visual_similarity"),
"video_fingerprint_md5": fp_result.get("video_fingerprint_md5", ""),
}
# ── Celery Task ──────────────────────────────────────────────────────────────
@@ -945,8 +964,8 @@ def generate_video(self, task_id: str) -> dict:
)
file_size = output_path.stat().st_size
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
dedup_info = _record_video_and_dedup(
# ── 4.5 预计算指纹/元信息(#2024: 不自动入成品库,finalize 时再落库+查重) ──
rendered_output = _precompute_render_metadata(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
@@ -958,68 +977,23 @@ def generate_video(self, task_id: str) -> dict:
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")
duration = rendered_output.get("duration", render_duration)
# #2024: 批次内重渲依赖已 finalize 的同批次视频。渲染阶段暂不做批次查重决策,
# 统一在 finalize 阶段查重;首版即视为最终渲染结果。
file_size_final = file_size
if gen_task:
gen_task.append_log(
"OSS上传",
f"第{render_attempt + 1}版上传成功, 大小={file_size}"
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
f"第{render_attempt + 1}版上传成功, 大小={file_size},等待用户确认封面",
file_size=file_size,
file_url=file_url,
)
_flush_logs(task_id, gen_task)
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
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)
render_temp_dir = None
# #2024: 不再因批次内相似度过高而重渲(finalize 阶段统一查重),
# 首版即视为最终渲染结果,直接结束循环。
break
file_size = file_size_final or file_size
_update_task_progress(task_id, 95, "上传完成")
@@ -1062,8 +1036,47 @@ def generate_video(self, task_id: str) -> dict:
except Exception:
logger.warning("[task_id=%s] 封面帧持久化失败", task_id, exc_info=True)
# ── 5. 标记完成 ──────────────────────────────────────────────────
_update_task_status(task_id, "mark_completed", result_count=video_count)
# ── 5. 保存渲染产物到 extra_meta 并标记为等待封面确认(#2024: 不自动入成品库) ──
try:
_finalize_meta_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_meta_model = (
_finalize_meta_session.query(GenerationTaskModel)
.filter(GenerationTaskModel.id == task_id)
.first()
)
if _meta_model:
meta = dict(_meta_model.extra_meta or {})
meta["rendered_output"] = {
"file_url": file_url,
"file_size": file_size,
"duration": duration,
"width": rendered_output.get("width", 1280),
"height": rendered_output.get("height", 720),
"fps": rendered_output.get("fps", 25.0),
"name": rendered_output.get("name", ""),
"thumbnail_url": rendered_output.get("thumbnail_url", ""),
"mode": rendered_output.get("mode", editing_mode.value),
"fingerprint_dict": rendered_output.get("fingerprint_dict"),
"batch_id": batch_id,
"project_id": project_id,
"user_id": user_id,
}
_meta_model.extra_meta = meta
_finalize_meta_session.commit()
finally:
_finalize_meta_session.close()
except Exception as meta_err:
logger.warning(
"[task_id=%s] 保存 rendered_output 到 extra_meta 失败: %s", task_id, meta_err, exc_info=True
)
# #2024: 标记为「等待用户确认封面」,不自动入成品库;等用户调 finalize 接口才真正 mark_completed
_update_task_status(task_id, "mark_awaiting_cover")
# 5.1 更新标题使用次数
try:
+8 -2
View File
@@ -808,17 +808,23 @@ def ingest_asset(job_id: str) -> dict:
db.commit()
# ── #1970 素材原子切片:视频 READY 后异步触发,失败不阻断入库 ──
# ── #1970 素材原子切片 + #2035 质量评分:视频 READY 后异步触发,失败不阻断入库 ──
# atom_clips 未就绪时选片逻辑有内存兜底(compute_fallback_clips)。
# quality_score 未计算时选片按 50 分兜底。
try:
if media_type == "video" and float(asset.duration or 0) > 0:
celery_app.send_task(
"worker.generate_atom_clips",
args=[asset.id],
)
# #2035: 异步质量评分(不与 atom_clips 链式耦合,独立任务)
celery_app.send_task(
"worker.calculate_asset_quality",
args=[asset.id],
)
except Exception as atom_err: # noqa: BLE001
logger.warning(
"触发原子切片任务失败(不影响入库): asset_id=%s err=%s",
"触发原子切片/质量评分任务失败(不影响入库): asset_id=%s err=%s",
asset.id,
atom_err,
)
+6
View File
@@ -12,6 +12,12 @@ server {
client_max_body_size 800m;
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
location / {
try_files $uri /index.html;
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
+7
View File
@@ -12,6 +12,13 @@ server {
client_max_body_size 800m;
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# SPA routing - all routes to index.html
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
location / {
@@ -83,6 +83,19 @@ class SQLAlchemyAssetAtomClipRepository:
models = query.all()
return [self._to_domain(m) for m in models]
def update_caption_embedding(self, clip_id: str, caption: str | None, embedding: list[float] | None = None) -> bool:
"""更新片段的 caption 和 embedding 字段。"""
upd: dict = {}
if caption is not None:
upd["caption"] = caption
if embedding is not None:
upd["embedding"] = embedding
if not upd:
return False
count = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update(upd)
self.session.commit()
return count > 0
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
"""更新指定片段的 ai_tags 字段."""
count = (
@@ -118,6 +131,8 @@ class SQLAlchemyAssetAtomClipRepository:
clip_index=clip.clip_index,
tags=clip.tags,
ai_tags=clip.ai_tags,
caption=clip.caption,
embedding=clip.embedding,
scene_change_at=clip.scene_change_at,
is_fallback=clip.is_fallback,
created_at=clip.created_at or datetime.now(UTC),
@@ -132,6 +147,9 @@ class SQLAlchemyAssetAtomClipRepository:
duration=model.duration,
clip_index=model.clip_index,
tags=model.tags or [],
ai_tags=getattr(model, "ai_tags", None),
caption=getattr(model, "caption", None),
embedding=getattr(model, "embedding", None),
scene_change_at=model.scene_change_at,
is_fallback=model.is_fallback,
created_at=model.created_at,
@@ -841,6 +841,8 @@ class AssetAtomClipModel(Base):
clip_index = Column(Integer, nullable=False)
tags = Column(JSON, nullable=False, default=list)
ai_tags = Column(JSON, nullable=True, default=None)
caption = Column(Text, nullable=True, default=None)
embedding = Column(JSON, nullable=True, default=None)
scene_change_at = Column(Float, nullable=True)
is_fallback = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,193 @@
"""#2024: 视频生成 finalize 入库用例。
Worker 渲染+上传完成后不自动入库,只把渲染产物与查重结果保存到
GenerationTask.extra_meta["rendered_output"],并标记为 awaiting_cover。
用户点「完成」时由 API 调用本用例:创建 GeneratedVideo 记录(复用预计算查重结果)、
推进任务到 completed,返回新记录 id。
设计原则:finalize 必须快速(仅 DB 写入,不下载视频、不重算指纹)——
所有耗时操作(指纹计算、历史/批次查重)都在 worker 渲染阶段预完成。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class RenderedOutput:
"""Worker 预计算并写入 extra_meta 的渲染产物+查重结果。"""
file_url: str
file_size: int = 0
duration: float = 0.0
width: int = 1280
height: int = 720
fps: float = 25.0
name: str = ""
thumbnail_url: str = ""
mode: str = "narrative"
batch_id: str = ""
project_id: str = ""
user_id: str = ""
# 查重结果(worker 预计算)
fingerprint_dict: dict[str, Any] | None = None
fingerprint_chunks: list[dict[str, Any]] | None = None
is_duplicate: bool = False
duplicate_of: str | None = None
duplicate_rate: float | None = None
match_count: int | None = None
visual_similarity: float | None = None
video_fingerprint_md5: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "RenderedOutput":
if not isinstance(data, dict):
raise ValueError("rendered_output must be a dict")
return cls(
file_url=str(data.get("file_url") or ""),
file_size=int(data.get("file_size") or 0),
duration=float(data.get("duration") or 0.0),
width=int(data.get("width") or 1280),
height=int(data.get("height") or 720),
fps=float(data.get("fps") or 25.0),
name=str(data.get("name") or ""),
thumbnail_url=str(data.get("thumbnail_url") or ""),
mode=str(data.get("mode") or "narrative"),
batch_id=str(data.get("batch_id") or ""),
project_id=str(data.get("project_id") or ""),
user_id=str(data.get("user_id") or ""),
fingerprint_dict=data.get("fingerprint_dict"),
fingerprint_chunks=data.get("fingerprint_chunks"),
is_duplicate=bool(data.get("is_duplicate", False)),
duplicate_of=data.get("duplicate_of"),
duplicate_rate=_safe_float(data.get("duplicate_rate")),
match_count=_safe_int(data.get("match_count")),
visual_similarity=_safe_float(data.get("visual_similarity")),
video_fingerprint_md5=str(data.get("video_fingerprint_md5") or ""),
)
def _safe_float(v) -> float | None:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _safe_int(v) -> int | None:
if v is None:
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def finalize_generated_video(
*,
task,
session: Session,
effective_cover_url: str = "",
custom_name: str | None = None,
) -> dict:
"""将 awaiting_cover 的任务正式入库。
从 ``task.extra_meta["rendered_output"]`` 读取 worker 预存的渲染结果与查重数据,
创建 GeneratedVideo 记录并 commit;调用方负责将 task 推进到 completed 并 update。
Returns:
{"video_id": str, "is_duplicate": bool, "duplicate_of": str|None}
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
from packages.domain.generated_video import GeneratedVideo
meta = dict(task.extra_meta or {})
rendered_dict = meta.get("rendered_output") or {}
rendered = RenderedOutput.from_dict(rendered_dict)
if not rendered.file_url.strip():
raise ValueError(f"task {task.id} rendered_output.file_url 为空,无法 finalize")
video_id = uuid4().hex
_custom = (custom_name or "").strip() if custom_name else ""
video_name = _custom or (rendered.name.strip() or f"generated-{task.id[:8]}.mp4")
generated_video = GeneratedVideo(
id=video_id,
project_id=(rendered.project_id or task.project_id or "").strip(),
user_id=(rendered.user_id or task.created_by_user_id or "").strip(),
generation_task_id=task.id,
name=video_name,
file_url=rendered.file_url.strip(),
file_size=rendered.file_size,
duration=rendered.duration,
width=rendered.width,
height=rendered.height,
fps=rendered.fps,
status="completed",
generation_params={"mode": rendered.mode},
thumbnail_url=effective_cover_url or rendered.thumbnail_url or None,
video_fingerprint=rendered.fingerprint_dict,
is_duplicate=rendered.is_duplicate,
duplicate_of=rendered.duplicate_of,
duplicate_rate=rendered.duplicate_rate,
match_count=rendered.match_count,
visual_similarity=rendered.visual_similarity,
created_at=datetime.now(UTC),
generated_at=datetime.now(UTC),
)
# 写入分片指纹(worker 预序列化的 chunk 列表)
if rendered.fingerprint_chunks:
try:
chunk_models = []
for c in rendered.fingerprint_chunks:
if not isinstance(c, dict):
continue
chunk_models.append(
VideoFingerprintChunkModel(
id=uuid4().hex,
video_id=video_id,
project_id=generated_video.project_id,
user_id=generated_video.user_id,
start_time_ms=int(c.get("start_time_ms", 0)),
end_time_ms=int(c.get("end_time_ms", 0)),
phash_binary=str(c.get("phash_binary", "")),
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
frame_count=int(c.get("frame_count", 0)),
)
)
if chunk_models:
session.bulk_save_objects(chunk_models)
except Exception as chunk_err:
logger.warning("Failed to persist fingerprint chunks for video %s: %s", video_id, chunk_err)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
session.commit()
logger.info(
"[finalize] GeneratedVideo created: %s (task=%s, dup=%s, cover=%s)",
video_id,
task.id,
rendered.is_duplicate,
bool(effective_cover_url),
)
return {
"video_id": video_id,
"is_duplicate": rendered.is_duplicate,
"duplicate_of": rendered.duplicate_of,
}
+1
View File
@@ -70,6 +70,7 @@ class SharedSettings(BaseSettings):
doubao_timeout: int = 30
doubao_max_retries: int = 2
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
mediakit_api_key: str = ""
+6
View File
@@ -37,6 +37,8 @@ class AssetAtomClip:
clip_index: int
tags: list[str] = field(default_factory=list)
ai_tags: dict | None = None
caption: str | None = None
embedding: list[float] | None = None
scene_change_at: float | None = None
is_fallback: bool = False
created_at: datetime | None = None
@@ -67,6 +69,8 @@ class AssetAtomClip:
tags: list[str] | None = None,
scene_change_at: float | None = None,
is_fallback: bool = False,
caption: str | None = None,
embedding: list[float] | None = None,
) -> AssetAtomClip:
"""工厂方法:创建一个新的原子片段。"""
return cls(
@@ -79,4 +83,6 @@ class AssetAtomClip:
tags=tags or [],
scene_change_at=scene_change_at,
is_fallback=is_fallback,
caption=caption,
embedding=embedding,
)
+112 -18
View File
@@ -23,36 +23,53 @@ from typing import Any, Optional
logger = logging.getLogger(__name__)
# AI 标签结构的键
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text")
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text", "person_count", "text_content", "caption")
def build_vision_prompt() -> str:
"""返回结构化标签提取 prompt.
要求 AI 以 JSON 格式返回片段内容标签,包含:
- scene: 场景类型列表(如 "工厂", "办公室", "户外")
- objects: 出现的物体列表(如 "产品", "手机", "电脑")
- action: 动作类型列表(如 "演示", "说话", "操作")
- scene: 场景类型列表(如 "工厂", "办公室", "户外", "家庭", "商店")
- objects: 画面中出现的主要物体/人物/动物类别,详细列出,常见类别包括:
人物类:"人物"/"男性"/"女性"/"儿童"
食物类:"食物"/"水果"/"饮料"/"菜肴"
电子设备类:"手机"/"电脑"/"笔记本"/"平板"/"电视"/"相机"
交通类:"汽车"/"自行车"/"公交车"/"飞机"
建筑类:"建筑"/"房屋"/"桥梁"/"道路"
动物类:"狗"/"猫"/"鸟"/"马"
其他常见:"桌子"/"椅子"/"书本"/"花草"/"产品"等
尽可能列出所有可识别的主要物体,3-8个
- action: 动作类型列表(如 "演示", "说话", "操作", "行走", "奔跑", "进食")
- shot: 景别("特写" / "中景" / "远景" 之一)
- has_text: 画面中是否有显著文字(true/false)
- has_text: 画面中是否有显著文字(标题/字幕/标语/海报文字)
- person_count: 画面中可见的人数,0/1/2/3(3代表3人及以上)
- text_content: 若 has_text=true,提取画面中最显著的文字内容(不超过30字,概括即可);否则为空字符串
- caption: 一句中文画面描述(15-30字),简洁概括这段视频的人物、动作、场景和主体内容
"""
return """请分析这段视频片段的关键帧,识别内容并返回 JSON 格式标签。
要求返回以下 JSON 结构(严格 JSON,不要添加其他文字):
{
"scene": ["场景1", "场景2"],
"objects": ["物体1", "物体2"],
"objects": ["物体1", "物体2", "物体3"],
"action": ["动作1"],
"shot": "特写|中景|远景",
"has_text": true/false
"has_text": true/false,
"person_count": 0,
"text_content": "",
"caption": "一句中文描述"
}
规则:
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"等,1-3个
- objects: 画面中可见的主要物体,如"产品"、"手机"、"电脑"、"食品"等,1-5个
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"等,1-3个
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"、"街道"等,1-3个
- objects: 画面中可见的所有主要物体/人物/动物/食物/设备等,详细列出(3-8个)。人物算作"人物",不要写具体人名。
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"、"行走"等,1-3个
- shot: 景别判断,只能是"特写"、"中景"或"远景"之一
- has_text: 画面中是否有显著可读文字(标题、字幕、标语等)
- has_text: 画面中是否有显著可读文字(标题、字幕、标语、海报文字等)
- person_count: 画面中可见的清晰人物数量,0=无人/远景人物不计数,1=1人,2=2人,3=3人及以上
- text_content: 仅当 has_text=true 时填写,提取画面中最显眼的文字内容(不要超过30字);has_text=false 时填空字符串
- caption: 一句简洁的中文画面描述(15-30字),概括主体人物、动作、场景和物体,例如"一名女性在办公室中讲解产品展示,桌上放有笔记本电脑"
请只返回 JSON,不要有其他说明文字。"""
@@ -65,7 +82,7 @@ def parse_vision_response(text: str) -> dict:
Returns:
结构化标签 dict,格式如:
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool}
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool, "person_count": int, "text_content": str, "caption": "..."}
解析失败时返回空 dict。
"""
@@ -127,6 +144,33 @@ def parse_vision_response(text: str) -> dict:
else:
result["has_text"] = False
cap_val = data.get("caption", "")
if isinstance(cap_val, str):
cap_val = cap_val.strip()
if len(cap_val) > 80:
cap_val = cap_val[:80]
else:
cap_val = ""
result["caption"] = cap_val
# person_count: 0/1/2/3
pc_val = data.get("person_count", 0)
try:
pc = int(pc_val)
result["person_count"] = max(0, min(3, pc))
except (TypeError, ValueError):
result["person_count"] = 0
# text_content: OCR 文字
tc_val = data.get("text_content", "")
if isinstance(tc_val, str):
tc_val = tc_val.strip()
if len(tc_val) > 100:
tc_val = tc_val[:100]
else:
tc_val = ""
result["text_content"] = tc_val
return result
@@ -237,14 +281,24 @@ def tag_atom_clip(
Returns:
结构化标签 dict,格式如:
{"scene": [...], "objects": [...], "action": [...], "shot": "...",
"has_text": bool, "inherited_tags": [...]}
"has_text": bool, "caption": "...", "inherited_tags": [...]}
"""
inherited = list(getattr(clip, "tags", []) or [])
# 检查 DoubaoClient 是否可用
if not getattr(doubao_client, "is_available", False):
logger.info("DoubaoClient 不可用,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
return {"inherited_tags": inherited}
return {
"scene": [],
"objects": [],
"action": [],
"shot": "",
"has_text": False,
"person_count": 0,
"text_content": "",
"caption": "",
"inherited_tags": inherited,
}
# 提取帧图片
frame_urls: Optional[list[str]] = None
@@ -261,7 +315,17 @@ def tag_atom_clip(
if not frame_urls:
logger.warning("帧提取失败,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
return {"inherited_tags": inherited}
return {
"scene": [],
"objects": [],
"action": [],
"shot": "",
"has_text": False,
"person_count": 0,
"text_content": "",
"caption": "",
"inherited_tags": inherited,
}
# 调用视觉 API
prompt = build_vision_prompt()
@@ -275,17 +339,47 @@ def tag_atom_clip(
)
except Exception as e:
logger.warning("视觉 API 调用异常: clip_id=%s error=%s", getattr(clip, "id", ""), e)
return {"inherited_tags": inherited}
return {
"scene": [],
"objects": [],
"action": [],
"shot": "",
"has_text": False,
"person_count": 0,
"text_content": "",
"caption": "",
"inherited_tags": inherited,
}
if not response_text:
logger.warning("视觉 API 返回空: clip_id=%s", getattr(clip, "id", ""))
return {"inherited_tags": inherited}
return {
"scene": [],
"objects": [],
"action": [],
"shot": "",
"has_text": False,
"person_count": 0,
"text_content": "",
"caption": "",
"inherited_tags": inherited,
}
# 解析标签
ai_tags = parse_vision_response(response_text)
if not ai_tags:
logger.warning("标签解析失败: clip_id=%s response=%s", getattr(clip, "id", ""), response_text[:200])
return {"inherited_tags": inherited}
return {
"scene": [],
"objects": [],
"action": [],
"shot": "",
"has_text": False,
"person_count": 0,
"text_content": "",
"caption": "",
"inherited_tags": inherited,
}
# 合并 inherited_tags
ai_tags["inherited_tags"] = inherited
+38 -4
View File
@@ -1,9 +1,13 @@
"""GenerationTask 领域模型 — 视频生成任务.
状态机:
pending → running → completed
pending → running → awaiting_cover → completed
↘ failed → pending (重试)
↘ cancelled
``awaiting_cover`` 表示渲染已完成、视频文件已上传、封面候选已就绪,
但用户尚未在 Step5 确认封面并点击「完成」,此时不创建 GeneratedVideo 成品记录。
用户调用 finalize 接口后才进入 ``completed`` 并正式入库。
"""
from __future__ import annotations
@@ -34,8 +38,11 @@ class GenerationTaskStatus(StrEnum):
RUNNING = "running"
"""运行中(正在生成视频)"""
AWAITING_COVER = "awaiting_cover"
"""视频已渲染上传、封面候选已就绪,等待用户在 Step5 确认封面(finalize 前的中间态)"""
COMPLETED = "completed"
"""已完成(视频生成成功)"""
"""已完成(用户已确认封面,视频已正式入库)"""
FAILED = "failed"
"""失败(生成失败)"""
@@ -61,6 +68,8 @@ class GenerationTaskStatus(StrEnum):
return cls.FAILED
if normalized in ("process", "processing", "run", "running", "in_progress"):
return cls.RUNNING
if normalized in ("awaiting_cover", "waiting_cover", "video_ready", "rendered", "pending_cover"):
return cls.AWAITING_COVER
if normalized in ("cancel", "cancelled", "canceled"):
return cls.CANCELLED
return cls.PENDING
@@ -79,6 +88,12 @@ _VALID_TRANSITIONS: dict[GenerationTaskStatus, set[GenerationTaskStatus]] = {
GenerationTaskStatus.CANCELLED,
},
GenerationTaskStatus.RUNNING: {
GenerationTaskStatus.AWAITING_COVER,
GenerationTaskStatus.COMPLETED, # 兜底/测试兼容:允许直接完成;主路径走 awaiting_cover
GenerationTaskStatus.FAILED,
GenerationTaskStatus.CANCELLED,
},
GenerationTaskStatus.AWAITING_COVER: {
GenerationTaskStatus.COMPLETED,
GenerationTaskStatus.FAILED,
GenerationTaskStatus.CANCELLED,
@@ -210,6 +225,11 @@ class GenerationTask:
"""是否运行中。"""
return self.status == GenerationTaskStatus.RUNNING
@property
def is_awaiting_cover(self) -> bool:
"""是否等待用户确认封面(渲染已完成、视频已上传、尚未 finalize 入库)。"""
return self.status == GenerationTaskStatus.AWAITING_COVER
# ── 状态转换 ────────────────────────────────────────────────────────────
def transition_to(self, new_status: GenerationTaskStatus | str) -> None:
@@ -248,13 +268,27 @@ class GenerationTask:
self.started_at = datetime.now(UTC)
self.error_message = ""
def mark_awaiting_cover(self) -> None:
"""标记为等待确认封面(running → awaiting_cover)。
渲染与上传已完成、封面候选已就绪,等待用户在 Step5 选封面并点「完成」。
此时不创建 GeneratedVideo 成品记录;progress 置 100,completed_at 暂不设置
(finalize 完成入库时才真正结束任务)。
Raises:
ValueError: 当前状态不允许转换到 awaiting_cover
"""
self.transition_to(GenerationTaskStatus.AWAITING_COVER)
self.progress = 100.0
self.error_message = ""
def mark_completed(self, result_count: int = 1) -> None:
"""标记为已完成(running → completed)。
"""标记为已完成(awaiting_cover → completed,由 finalize 调用)。
设置 completed_at、progress=100.0、result_count,清除 error_message。
Args:
result_count: 生成的视频数量,默认为 1
result_count: 入库的视频数量,默认为 1
Raises:
ValueError: 当前状态不允许转换到 completed
+28 -5
View File
@@ -245,16 +245,39 @@ def pick_narrative_assets(
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
)
# #2035:把文案标签与聚合的素材级 ai_tags 透传给 smart_select_assets,
# 让 smart 评分维度(ai_semantic)在叙事模式内部兜底/补位时同样生效。
wanted_norm = _normalize_tags(script_tags)
asset_ai_tags: dict[str, dict] = {}
if clip_ai_tags_by_asset:
for aid, clips in clip_ai_tags_by_asset.items():
agg: dict = {"scene": [], "objects": [], "action": []}
for clip_tags in clips or []:
if not isinstance(clip_tags, dict):
continue
for key in ("scene", "objects", "action"):
for v in clip_tags.get(key) or []:
v = str(v).strip()
if v and v not in agg[key]:
agg[key].append(v)
asset_ai_tags[aid] = agg
smart_kwargs = dict(
kind="video",
rng=rng,
script_tags=wanted_norm if wanted_norm else None,
ai_tags_by_asset=asset_ai_tags if asset_ai_tags else None,
)
need = limit if (limit is not None and limit > 0) else None
if not matched:
# 完全降级:与改造前随机混剪同一逻辑
return [r.asset for r in smart_select_assets(assets, kind="video", limit=need, rng=rng)]
return [r.asset for r in smart_select_assets(assets, limit=need, **smart_kwargs)]
picked = [r.asset for r in smart_select_assets(matched, kind="video", limit=need, rng=rng)]
picked = [r.asset for r in smart_select_assets(matched, limit=need, **smart_kwargs)]
if need is not None and len(picked) < need and unmatched:
rest_need = need - len(picked)
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", limit=rest_need, rng=rng))
picked.extend(r.asset for r in smart_select_assets(unmatched, limit=rest_need, **smart_kwargs))
elif need is None:
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", rng=rng))
picked.extend(r.asset for r in smart_select_assets(unmatched, **smart_kwargs))
return picked
+83 -11
View File
@@ -40,6 +40,14 @@ def _get_enum_value(obj: Any, attr: str) -> str:
return val.value if hasattr(val, "value") else str(val)
def normalize_tag(tag) -> str:
"""标准化标签:去两端空白、小写;非 str 转 str。返回空串表示应丢弃。"""
if tag is None:
return ""
s = str(tag).strip().lower()
return s
def _duration_bucket(duration: float | None) -> str:
"""将素材时长分为 3 档:short(<10s) / medium(10-30s) / long(>30s)。"""
if duration is None or duration <= 0:
@@ -54,14 +62,24 @@ def _duration_bucket(duration: float | None) -> str:
def score_asset(
asset: Any,
now: datetime | None = None,
script_tags: set | None = None,
ai_tags_by_asset: dict | None = None,
expected_categories: set[str] | None = None,
) -> tuple[float, dict[str, float]]:
"""为单个素材计算综合得分(0-100)。
维度权重:
- quality_score (40%):素材质量分(0-100),无质量分按 50 计
- duration_fitness (30%):时长适配度,5-30s 为最优区间
- recency (20%):新鲜度,30 天内衰减
- unused_bonus (10%):未被使用过的素材加分
维度权重(#2035 加入 AI 语义匹配 + 素材分类维度):
- quality_score (28%):素材质量分(0-100),无质量分按 50 计
- duration_fitness (22%):时长适配度,5-30s 为最优区间
- recency (12%):新鲜度,30 天内衰减
- unused (8%):未被/少被使用过的素材加分
- ai_semantic (20%):AI 标签(scene/objects/action)与文案标签重合度;无数据给 50 中性分
- category_match (10%):FFmpeg 自动分类结果(scenic/product/person/animal/food/tech/sport/music)
与期望类别重合度;无分类或无期望类别时给 60 中性分
Args:
script_tags: 标准化后的文案标签集合,用于 AI 语义匹配维度打分。
ai_tags_by_asset: asset_id → ai_tags dict 映射,ai_tags 含 scene/objects/action 字段。
Returns:
(total_score, breakdown_dict)
@@ -73,7 +91,7 @@ def score_asset(
# 1. 质量分 (0-100) → 权重 40%
raw_quality = asset.quality_score if asset.quality_score is not None else 50.0
quality_component = raw_quality * 0.4
quality_component = raw_quality * 0.28
breakdown["quality"] = round(quality_component, 2)
# 2. 时长适配度 (0-100) → 权重 30%
@@ -90,7 +108,7 @@ def score_asset(
# >30s: 指数衰减,60s 时约 50 分
duration_fitness = 100.0 * math.exp(-0.02 * (duration - 30))
duration_fitness = max(duration_fitness, 10.0)
duration_component = duration_fitness * 0.3
duration_component = duration_fitness * 0.22
breakdown["duration"] = round(duration_component, 2)
# 3. 新鲜度 (0-100) → 权重 20%
@@ -103,7 +121,7 @@ def score_asset(
created_at = created_at.replace(tzinfo=UTC)
age_days = max(0, (now - created_at).total_seconds() / 86400)
recency = 100.0 * math.exp(-0.05 * age_days) # ~14天半衰期
recency_component = recency * 0.2
recency_component = recency * 0.12
breakdown["recency"] = round(recency_component, 2)
# 4. 未使用偏好 (0-100) → 权重 10%
@@ -118,10 +136,55 @@ def score_asset(
unused_score = 70.0
else:
unused_score = 30.0
unused_component = unused_score * 0.1
unused_component = unused_score * 0.08
breakdown["unused"] = round(unused_component, 2)
total = quality_component + duration_component + recency_component + unused_component
# 5. AI 语义匹配 (0-100) → 权重 20%
if script_tags and ai_tags_by_asset:
asset_ai = ai_tags_by_asset.get(getattr(asset, "id", "")) or {}
ai_terms: set = set()
for key in ("scene", "objects", "action"):
vals = asset_ai.get(key) or []
if isinstance(vals, list):
for v in vals:
norm = normalize_tag(v)
if norm:
ai_terms.add(norm)
if ai_terms:
norm_script = {normalize_tag(t) for t in script_tags if normalize_tag(t)}
overlap = ai_terms & norm_script
union = ai_terms | norm_script
ratio = (len(overlap) / len(union)) if union else 0.0
if overlap:
ai_score = 50.0 + 50.0 * ratio
else:
ai_score = 20.0
else:
ai_score = 50.0
else:
ai_score = 50.0
ai_component = ai_score * 0.20
breakdown["ai_semantic"] = round(ai_component, 2)
# 6. 分类匹配 (0-100) → 权重 10%
asset_meta = getattr(asset, "metadata", None) or {}
asset_cat = (asset_meta.get("classification") or "").strip().lower()
if expected_categories and asset_cat:
norm_expected = {c.strip().lower() for c in expected_categories if c and c.strip()}
if asset_cat == "other":
cat_score = 50.0 # other 类不给额外加分也不扣分
elif asset_cat in norm_expected:
cat_score = 100.0
else:
cat_score = 30.0 # 分类明确但不匹配,略扣分
elif expected_categories:
cat_score = 60.0 # 无分类结果,中性
else:
cat_score = 60.0 # 无期望类别,中性
cat_component = cat_score * 0.10
breakdown["category_match"] = round(cat_component, 2)
total = quality_component + duration_component + recency_component + unused_component + ai_component + cat_component
return round(total, 2), breakdown
@@ -132,6 +195,9 @@ def smart_select_assets(
kind: str | None = None,
now: datetime | None = None,
rng: random.Random | None = None,
script_tags: set | None = None,
ai_tags_by_asset: dict | None = None,
expected_categories: set[str] | None = None,
) -> list[SmartMatchResult]:
"""从素材列表中智能选取素材。
@@ -159,7 +225,13 @@ def smart_select_assets(
# Step 3: 评分
scored: list[SmartMatchResult] = []
for a in ready_assets:
total, breakdown = score_asset(a, now=now)
total, breakdown = score_asset(
a,
now=now,
script_tags=script_tags,
ai_tags_by_asset=ai_tags_by_asset,
expected_categories=expected_categories,
)
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
# Step 4: 按「得分 + 随机噪声」降序排序
+41
View File
@@ -39,6 +39,47 @@ class DoubaoClient:
self.max_retries: int = settings.doubao_max_retries
self.vision_model: str = settings.doubao_vision_model
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
if not self.is_available or not text or not text.strip():
return None
url = f"{self.base_url}/embeddings"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": getattr(self, "embedding_model", None) or "doubao-embedding-large-text-240915",
"input": text.strip(),
"encoding_format": "float",
}
req_timeout = timeout or self.timeout
last_error: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
resp = httpx.post(url, headers=headers, json=payload, timeout=req_timeout)
resp.raise_for_status()
data = resp.json()
emb_list = data.get("data") or []
if emb_list and isinstance(emb_list, list):
vec = emb_list[0].get("embedding")
if isinstance(vec, list) and vec:
return [float(x) for x in vec]
logger.warning("embedding 返回结构异常: %s", str(data)[:200])
return None
except Exception as e:
last_error = e
if attempt < self.max_retries:
wait = 0.5 * (2**attempt)
logger.warning(
"豆包 Embedding 调用失败,%.1fs 后重试 (%d/%d): %s", wait, attempt + 1, self.max_retries + 1, e
)
time.sleep(wait)
logger.error("豆包 Embedding 调用最终失败: %s", last_error)
return None
@property
def is_available(self) -> bool:
"""是否可用(配置了 API Key)."""
+3 -2
View File
@@ -13,8 +13,9 @@ class TestGenerationTaskStatus:
"""GenerationTaskStatus 枚举测试."""
def test_five_statuses(self):
"""五种状态."""
assert len(GenerationTaskStatus) == 5
"""六种状态(#2024 新增 awaiting_cover)."""
assert len(GenerationTaskStatus) == 6
assert GenerationTaskStatus.AWAITING_COVER == "awaiting_cover"
def test_pending(self):
assert GenerationTaskStatus.PENDING == "pending"
+32 -6
View File
@@ -210,7 +210,12 @@ class TestTagAtomClip:
doubao_client=fake_doubao,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
assert result["inherited_tags"] == ["tag1", "tag2"]
assert result.get("caption", "") == ""
assert result.get("scene", []) == []
assert result.get("objects", []) == []
assert result.get("action", []) == []
assert "inherited_tags" in result
assert len(fake_doubao.vision_calls) == 0
def test_mediakit_unavailable_no_ffmpeg(self):
@@ -227,7 +232,12 @@ class TestTagAtomClip:
)
# 没有 ffmpeg 的情况下,帧提取失败
assert result == {"inherited_tags": ["tag1", "tag2"]}
assert result["inherited_tags"] == ["tag1", "tag2"]
assert result.get("caption", "") == ""
assert result.get("scene", []) == []
assert result.get("objects", []) == []
assert result.get("action", []) == []
assert "inherited_tags" in result
def test_vision_api_error_returns_inherited(self):
"""视觉 API 抛异常 → 降级 inherited_tags."""
@@ -242,7 +252,12 @@ class TestTagAtomClip:
mediakit_client=fake_mediakit,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
assert result["inherited_tags"] == ["tag1", "tag2"]
assert result.get("caption", "") == ""
assert result.get("scene", []) == []
assert result.get("objects", []) == []
assert result.get("action", []) == []
assert "inherited_tags" in result
def test_vision_api_empty_response(self):
"""视觉 API 返回空 → 降级 inherited_tags."""
@@ -257,7 +272,12 @@ class TestTagAtomClip:
mediakit_client=fake_mediakit,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
assert result["inherited_tags"] == ["tag1", "tag2"]
assert result.get("caption", "") == ""
assert result.get("scene", []) == []
assert result.get("objects", []) == []
assert result.get("action", []) == []
assert "inherited_tags" in result
def test_vision_api_invalid_json_response(self):
"""视觉 API 返回无效 JSON → 降级 inherited_tags."""
@@ -272,7 +292,12 @@ class TestTagAtomClip:
mediakit_client=fake_mediakit,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
assert result["inherited_tags"] == ["tag1", "tag2"]
assert result.get("caption", "") == ""
assert result.get("scene", []) == []
assert result.get("objects", []) == []
assert result.get("action", []) == []
assert "inherited_tags" in result
def test_clip_with_empty_tags(self):
"""空素材标签 → inherited_tags 为空列表."""
@@ -285,7 +310,8 @@ class TestTagAtomClip:
doubao_client=fake_doubao,
)
assert result == {"inherited_tags": []}
assert result["inherited_tags"] == []
assert result.get("caption", "") == ""
if __name__ == "__main__":
+262
View File
@@ -0,0 +1,262 @@
"""Additional unit tests to hit uncovered lines for diff-coverage >=60%."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from packages.shared.ai_client import DoubaoClient
class _FakeSettings:
doubao_api_key = "test-key"
doubao_model = "test-model"
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout = 10
doubao_max_retries = 0
doubao_vision_model = "test-vision"
doubao_embedding_model = "test-embedding"
def _make_client(api_key: str = "test-key") -> DoubaoClient:
with patch("packages.shared.ai_client.get_shared_settings", return_value=_FakeSettings()):
c = DoubaoClient()
c.api_key = api_key
c.max_retries = 0
return c
class TestDoubaoClientEmbedText:
def test_no_api_key_returns_none(self):
c = _make_client(api_key="")
assert c.embed_text("hello") is None
def test_empty_text_returns_none(self):
c = _make_client()
assert c.embed_text("") is None
assert c.embed_text(" ") is None
def test_none_text_returns_none(self):
c = _make_client()
assert c.embed_text(None) is None
@patch("packages.shared.ai_client.httpx.post")
def test_successful_embedding(self, mock_post):
mock_resp = MagicMock()
mock_resp.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
c = _make_client()
result = c.embed_text("hello world")
assert result == [0.1, 0.2, 0.3]
mock_post.assert_called_once()
@patch("packages.shared.ai_client.httpx.post")
def test_malformed_response_returns_none(self, mock_post):
mock_resp = MagicMock()
mock_resp.json.return_value = {"data": []}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
c = _make_client()
assert c.embed_text("hello") is None
@patch("packages.shared.ai_client.httpx.post", side_effect=Exception("network error"))
def test_network_error_returns_none(self, mock_post):
c = _make_client()
assert c.embed_text("hello") is None
def test_is_available_with_key(self):
c = _make_client(api_key="sk-xxx")
assert c.is_available is True
def test_is_available_without_key(self):
c = _make_client(api_key="")
assert c.is_available is False
# --- 2. _infer_expected_categories ---
_GEN_TASKS_PATH = Path(__file__).resolve().parents[2] / "apps/api/app/api/routes/generation_tasks.py"
def _load_infer_func():
src = _GEN_TASKS_PATH.read_text()
start = src.index("# #2035:文案关键词")
end = src.index("from packages.middleware")
code = src[start:end]
ns: dict = {}
exec(code, ns)
return ns["_infer_expected_categories"]
_infer_expected_categories = _load_infer_func()
class TestInferExpectedCategories:
def test_none_returns_none(self):
assert _infer_expected_categories(None) is None
assert _infer_expected_categories(set()) is None
def test_product_keyword_matches(self):
cats = _infer_expected_categories({"产品展示"})
assert cats is not None
assert "product" in cats
def test_scenic_keyword_matches(self):
cats = _infer_expected_categories({"户外风景"})
assert cats is not None
assert "scenic" in cats
def test_food_keyword_matches(self):
cats = _infer_expected_categories({"美食制作"})
assert cats is not None
assert "food" in cats
def test_no_match_returns_none(self):
assert _infer_expected_categories({"抽象概念xyz"}) is None
# --- 3. parse_vision_response edge cases ---
from packages.domain.atom_clip_tagger import parse_vision_response
class TestParseVisionResponseEdgeCases:
def test_person_count_type_error_defaults_zero(self):
text = json.dumps({
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
"person_count": "not-an-int", "text_content": "", "caption": "x",
})
r = parse_vision_response(text)
assert r["person_count"] == 0
def test_person_count_out_of_range_clamped(self):
text = json.dumps({
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
"person_count": 10, "text_content": "", "caption": "x",
})
r = parse_vision_response(text)
assert r["person_count"] == 3
def test_person_count_negative_clamped(self):
text = json.dumps({
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
"person_count": -5, "text_content": "", "caption": "x",
})
r = parse_vision_response(text)
assert r["person_count"] == 0
def test_text_content_non_string_defaults_empty(self):
text = '{"scene":[],"objects":[],"action":[],"shot":"","has_text":true,"person_count":0,"text_content":123,"caption":"x"}'
r = parse_vision_response(text)
assert r["text_content"] == ""
def test_caption_truncation_at_80(self):
long_caption = "描" * 100
text = json.dumps({
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
"person_count": 0, "text_content": "", "caption": long_caption,
})
r = parse_vision_response(text)
assert len(r["caption"]) == 80
# --- 4. smart_match normalize_tag ---
from packages.domain.smart_match import normalize_tag
class TestNormalizeTagEdge:
def test_none_returns_empty(self):
assert normalize_tag(None) == ""
def test_non_string_converted(self):
assert normalize_tag(123) == "123"
def test_strip_and_lower(self):
assert normalize_tag(" FOO Bar ") == "foo bar"
# --- 5. narrative_match non-dict clip_tags skip ---
from packages.domain.narrative_match import match_assets_by_script_tags
@dataclass
class _FA:
id: str
tags: list
class TestNarrativeMatchNonDictClipTags:
def test_non_dict_clip_tags_are_skipped(self):
a1 = _FA("a1", tags=[])
clip_map = {"a1": [None, "bad", {"scene": ["工厂"], "objects": [], "action": []}, 123]}
matched, unmatched = match_assets_by_script_tags(
[a1], script_tags=["工厂"], clip_ai_tags_by_asset=clip_map
)
assert [a.id for a in matched] == ["a1"]
# --- 6. update_caption_embedding ---
class _FakeSession:
def __init__(self, rows_found: int = 1):
self.rows_found = rows_found
self.commits = 0
self.updates = []
def query(self, model):
return _FQuery(self)
def commit(self):
self.commits += 1
class _FQuery:
def __init__(self, session):
self.session = session
def filter(self, *a, **kw):
return self
def update(self, upd):
self.session.updates.append(upd)
return self.session.rows_found
class TestUpdateCaptionEmbedding:
def _make_repo(self, session):
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import SQLAlchemyAssetAtomClipRepository
repo = SQLAlchemyAssetAtomClipRepository.__new__(SQLAlchemyAssetAtomClipRepository)
repo.session = session
return repo
def test_updates_both_caption_and_embedding(self):
s = _FakeSession(rows_found=1)
repo = self._make_repo(s)
ok = repo.update_caption_embedding("c1", "new caption", [0.1, 0.2])
assert ok is True
assert s.commits == 1
assert s.updates[0]["caption"] == "new caption"
assert s.updates[0]["embedding"] == [0.1, 0.2]
def test_only_caption_update(self):
s = _FakeSession(rows_found=1)
repo = self._make_repo(s)
ok = repo.update_caption_embedding("c1", "cap", None)
assert ok is True
assert "embedding" not in s.updates[0]
assert s.updates[0]["caption"] == "cap"
def test_no_update_when_both_none(self):
s = _FakeSession()
repo = self._make_repo(s)
ok = repo.update_caption_embedding("c1", None, None)
assert ok is False
assert s.commits == 0
assert s.updates == []
def test_returns_false_when_row_not_found(self):
s = _FakeSession(rows_found=0)
repo = self._make_repo(s)
ok = repo.update_caption_embedding("c1", "x", [0.1])
assert ok is False
+352
View File
@@ -0,0 +1,352 @@
"""#2035 语义标签增强 / 质量评分 / AI选片 单测。"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import pytest
from packages.domain.asset_atom_clip import AssetAtomClip
from packages.domain.atom_clip_tagger import parse_vision_response
from packages.domain.narrative_match import (
_compute_ai_score,
_extract_ai_tag_names,
match_assets_by_script_tags,
pick_narrative_assets,
)
from packages.domain.smart_match import score_asset, smart_select_assets
# ── helpers ──────────────────────────────────────────────────────
@dataclass
class FakeAsset:
id: str
tag_ids: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
status: object = None
file_type: str = "video"
duration: float = 10.0
quality_score: float | None = 50.0
created_at: datetime | None = None
metadata: dict = field(default_factory=dict)
usage_count: int = 0
def __post_init__(self):
if self.status is None:
class _S:
value = "ready"
self.status = _S()
if self.created_at is None:
self.created_at = datetime.now(UTC) - timedelta(days=1)
# ── parse_vision_response: caption 提取 ─────────────────────────
class TestParseVisionResponseCaption:
def test_extracts_caption(self):
text = '{"scene":["办公室"],"objects":["电脑","人"],"action":["说话"],"shot":"中景","has_text":false,"caption":"职场女性在办公室讲解产品功能"}'
result = parse_vision_response(text)
assert result["caption"] == "职场女性在办公室讲解产品功能"
assert result["has_text"] is False
assert result["scene"] == ["办公室"]
def test_caption_truncated_at_80(self):
long = "A" * 100
text = '{"scene":[],"objects":[],"action":[],"shot":"中景","has_text":false,"caption":"' + long + '"}'
result = parse_vision_response(text)
assert len(result["caption"]) == 80
def test_missing_caption_defaults_empty(self):
text = '{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":true}'
result = parse_vision_response(text)
assert result["caption"] == ""
def test_empty_input_returns_empty_dict(self):
assert parse_vision_response("") == {}
assert parse_vision_response(None) == {}
# ── AI 标签提取 ──────────────────────────────────────────────────
class TestExtractAiTagNames:
def test_extracts_scene_objects_action(self):
tags = {"scene": ["办公室"], "objects": ["电脑", "杯子"], "action": ["说话"], "shot": "中景", "has_text": False}
names = _extract_ai_tag_names(tags)
assert "办公室" in names
assert "电脑" in names
assert "杯子" in names
assert "说话" in names
assert "中景" not in names # shot 不参与匹配
def test_empty_tags(self):
assert _extract_ai_tag_names({}) == set()
assert _extract_ai_tag_names({"scene": []}) == set()
# ── _compute_ai_score ───────────────────────────────────────────
class TestComputeAiScore:
def test_basic_hit(self):
clip_map = {"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}]}
score = _compute_ai_score("a1", {"工厂", "演示"}, clip_map)
# 2 hits * weight 2.0 = 4.0
assert score == 4.0
def test_no_hit(self):
clip_map = {"a1": [{"scene": ["户外"], "objects": [], "action": []}]}
assert _compute_ai_score("a1", {"办公室"}, clip_map) == 0.0
def test_no_clip_map(self):
assert _compute_ai_score("a1", {"工厂"}, None) == 0.0
assert _compute_ai_score("a1", set(), {"a1": [{"scene": ["x"]}]}) == 0.0
def test_best_clip_score_not_sum(self):
"""多片段取最高得分,不是累加。"""
clip_map = {
"a1": [
{"scene": ["工厂"], "objects": [], "action": []}, # 1 hit
{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}, # 3 hits
{"scene": ["户外"], "objects": [], "action": []}, # 0
]
}
score = _compute_ai_score("a1", {"工厂", "产品", "演示"}, clip_map)
assert score == 3 * 2.0 # best = 6.0, not (1+3+0)*2 = 8.0
# ── match_assets_by_script_tags 接受 clip_ai_tags_by_asset ──────
class TestMatchSplitAiTags:
def test_ai_hit_only_puts_in_matched(self):
"""素材无人工标签,但 AI 标签命中 → 命中池。"""
assets = [FakeAsset("a1", tags=[]), FakeAsset("a2", tags=["旅游"])]
clip_map = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
matched, unmatched = match_assets_by_script_tags(assets, script_tags=["工厂"], clip_ai_tags_by_asset=clip_map)
assert [a.id for a in matched] == ["a1"]
assert [a.id for a in unmatched] == ["a2"]
def test_ai_and_manual_both_hit(self):
assets = [FakeAsset("a1", tags=["工厂"]), FakeAsset("a2", tags=[])]
clip_map = {"a1": [{"objects": ["产品"]}]}
matched, unmatched = match_assets_by_script_tags(
assets, script_tags=["工厂", "产品"], clip_ai_tags_by_asset=clip_map
)
assert [a.id for a in matched] == ["a1"]
# a2 无人标签也无AI命中 → unmatched
assert [a.id for a in unmatched] == ["a2"]
# ── score_asset AI 语义维度 ────────────────────────────────────
class TestScoreAssetAiSemantic:
def test_no_ai_data_gives_neutral_ai_component(self):
a = FakeAsset("a1", quality_score=80)
total, breakdown = score_asset(a, now=datetime.now(UTC))
# ai_semantic 中性分 50 * 0.20 = 10;category 中性分 60 * 0.10 = 6
assert breakdown["ai_semantic"] == 10.0
assert breakdown["category_match"] == 6.0
def test_ai_hit_boosts_score(self):
a = FakeAsset("a1", quality_score=50)
ai_map = {"a1": {"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}}
total_hit, _ = score_asset(a, now=datetime.now(UTC), script_tags={"工厂", "产品"}, ai_tags_by_asset=ai_map)
total_miss, _ = score_asset(a, now=datetime.now(UTC), script_tags={"旅游"}, ai_tags_by_asset=ai_map)
total_neutral, _ = score_asset(a, now=datetime.now(UTC))
assert total_hit > total_neutral
assert total_neutral > total_miss
def test_weights_sum_to_100(self):
a = FakeAsset("a1", quality_score=100, duration=15)
a.created_at = datetime.now(UTC)
a.metadata = {"generation_use_count": 0}
_, bd = score_asset(a, now=datetime.now(UTC))
# 满分素材:quality=28, duration=22, recency=~12 (new), unused=8, ai=10(neutral), cat=6(neutral)
# 总和应该 ~86
assert 80 <= sum(bd.values()) <= 100.5
# ── smart_select_assets 接受 script_tags/ai_tags_by_asset ──────
class TestSmartSelectAi:
def test_ai_hit_ranks_higher(self):
a1 = FakeAsset("a1", quality_score=50, duration=15)
a2 = FakeAsset("a2", quality_score=50, duration=15)
a3 = FakeAsset("a3", quality_score=50, duration=15)
ai_map = {
"a1": {"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]},
"a2": {"scene": ["户外"], "objects": [], "action": []},
"a3": {},
}
rng = random.Random(42)
results = smart_select_assets(
[a1, a2, a3],
kind="video",
rng=rng,
script_tags={"工厂", "产品", "演示"},
ai_tags_by_asset=ai_map,
)
assert results[0].asset.id == "a1" # AI 命中应排第一
def test_without_ai_params_works_as_before(self):
a1 = FakeAsset("a1", quality_score=80, duration=15)
a2 = FakeAsset("a2", quality_score=40, duration=15)
rng = random.Random(0)
results = smart_select_assets([a1, a2], kind="video", rng=rng)
assert results[0].asset.id == "a1"
# ── pick_narrative_assets 接受 clip_ai_tags_by_asset ────────────
class TestPickNarrativeAi:
def test_ai_tagged_assets_selected_first(self):
a1 = FakeAsset("a1", tags=[])
a2 = FakeAsset("a2", tags=[])
a3 = FakeAsset("a3", tags=["无关"])
clip_map = {
"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}],
"a2": [{"scene": ["户外"], "objects": [], "action": []}],
}
rng = random.Random(0)
picked = pick_narrative_assets(
[a1, a2, a3],
script_tags=["工厂", "产品"],
tag_names_by_id={},
clip_ai_tags_by_asset=clip_map,
rng=rng,
limit=2,
)
assert picked[0].id == "a1" # a1 命中 AI 标签应在首位
assert {a.id for a in picked} == {"a1", "a3"} or {a.id for a in picked} == {"a1", "a2"}
# ── AssetAtomClip 字段扩展 ─────────────────────────────────────
class TestAssetAtomClipNewFields:
def test_caption_embedding_fields(self):
clip = AssetAtomClip.create(
asset_id="a1",
start_time=0,
end_time=5,
clip_index=0,
tags=[],
caption="测试画面描述",
embedding=[0.1, 0.2, 0.3],
)
assert clip.caption == "测试画面描述"
assert clip.embedding == [0.1, 0.2, 0.3]
assert clip.ai_tags is None
def test_default_fields_none(self):
clip = AssetAtomClip.create("a1", 0, 5, 0)
assert clip.caption is None
assert clip.embedding is None
# ── parse_vision_response: person_count / text_content ───────────
class TestParseVisionResponseEnhanced:
def test_person_count_parsed(self):
text = '{"scene":["办公室"],"objects":["人物","电脑"],"action":["说话"],"shot":"中景","has_text":false,"person_count":1,"text_content":"","caption":"职场女性在办公室讲解产品功能,桌上有笔记本电脑"}'
result = parse_vision_response(text)
assert result["person_count"] == 1
assert result["text_content"] == ""
def test_person_count_multi_people(self):
text = '{"scene":["会议室"],"objects":["人物","桌子","椅子"],"action":["开会"],"shot":"中景","has_text":false,"person_count":3,"text_content":"","caption":"多人在会议室开会讨论项目方案"}'
result = parse_vision_response(text)
assert result["person_count"] == 3 # 3人及以上
def test_person_count_non_int_defaults_zero(self):
text = '{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":false,"person_count":"abc","text_content":"","caption":""}'
result = parse_vision_response(text)
assert result["person_count"] == 0
def test_text_content_extracted_when_has_text(self):
text = '{"scene":["街道"],"objects":["招牌","建筑"],"action":[],"shot":"远景","has_text":true,"person_count":0,"text_content":"欢迎光临","caption":"街道上有一家店铺招牌写着欢迎光临"}'
result = parse_vision_response(text)
assert result["text_content"] == "欢迎光临"
assert result["has_text"] is True
def test_text_content_truncated_at_100(self):
long_text = "X" * 200
text = (
'{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":true,"person_count":0,"text_content":"'
+ long_text
+ '","caption":""}'
)
result = parse_vision_response(text)
assert len(result["text_content"]) == 100
def test_missing_person_count_defaults_zero(self):
text = '{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":false,"caption":"一个苹果"}'
result = parse_vision_response(text)
assert result["person_count"] == 0
assert result["text_content"] == ""
def test_fallback_returns_person_count_zero(self):
"""非 JSON 输入应返回空 dict(不是 fallback tags)。"""
result = parse_vision_response("not json at all")
assert result == {}
def test_objects_list_merged(self):
"""objects 应该被保留并转为列表。"""
text = '{"scene":["厨房"],"objects":["食物","锅","蔬菜","刀具"],"action":["烹饪"],"shot":"中景","has_text":false,"person_count":1,"text_content":"","caption":"厨师在厨房烹饪食物,食材摆放整齐"}'
result = parse_vision_response(text)
assert "食物" in result["objects"]
assert "锅" in result["objects"]
assert "蔬菜" in result["objects"]
assert len(result["objects"]) >= 3
# ── score_asset category_match 维度 ────────────────────────────
class TestScoreAssetCategoryMatch:
def test_no_category_gives_neutral(self):
a = FakeAsset("a1", quality_score=50, metadata={})
_, bd = score_asset(a, now=datetime.now(UTC))
assert bd["category_match"] == 6.0 # 60 * 0.10 = 6
def test_category_hit_gives_10(self):
a = FakeAsset("a1", quality_score=50, metadata={"classification": "product"})
_, bd = score_asset(a, now=datetime.now(UTC), expected_categories={"product", "person"})
assert bd["category_match"] == 10.0 # 100 * 0.10 = 10
def test_category_miss_gives_low(self):
a = FakeAsset("a1", quality_score=50, metadata={"classification": "scenic"})
_, bd_hit = score_asset(a, now=datetime.now(UTC), expected_categories={"product"})
_, bd_neutral = score_asset(a, now=datetime.now(UTC))
assert bd_hit["category_match"] == 3.0 # 30 * 0.10 = 3
assert bd_neutral["category_match"] == 6.0
def test_other_category_neutral(self):
"""other 类不给额外加分。"""
a = FakeAsset("a1", quality_score=50, metadata={"classification": "other"})
_, bd = score_asset(a, now=datetime.now(UTC), expected_categories={"product"})
assert bd["category_match"] == 5.0 # 50 * 0.10 = 5
def test_category_affects_ranking(self):
a_product = FakeAsset("a_product", quality_score=50, duration=15, metadata={"classification": "product"})
a_scenic = FakeAsset("a_scenic", quality_score=50, duration=15, metadata={"classification": "scenic"})
a_none = FakeAsset("a_none", quality_score=50, duration=15, metadata={})
rng = random.Random(42)
results = smart_select_assets(
[a_product, a_scenic, a_none],
kind="video",
rng=rng,
expected_categories={"product"},
)
assert results[0].asset.id == "a_product"
+167
View File
@@ -0,0 +1,167 @@
"""#2028: generation_cover._get_task_video_url 兜底逻辑测试。"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
class TestGetTaskVideoUrlAwaitingCoverFallback:
def test_returns_url_from_rendered_output_when_awaiting_cover(self):
from app.api.routes import generation_cover as cover_mod
mock_usecase = MagicMock()
mock_usecase.execute.return_value = []
mock_task = MagicMock()
mock_task.status.value = "awaiting_cover"
mock_task.extra_meta = {"rendered_output": {"file_url": "oss://generated/awaiting.mp4"}}
mock_task_repo_instance = MagicMock()
mock_task_repo_instance.get.return_value = mock_task
mock_db = MagicMock()
with (
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
):
url = cover_mod._get_task_video_url("task-aw1", mock_db)
assert url == "oss://generated/awaiting.mp4"
def test_returns_none_when_status_not_awaiting_cover(self):
from app.api.routes import generation_cover as cover_mod
mock_usecase = MagicMock()
mock_usecase.execute.return_value = []
mock_task = MagicMock()
mock_task.status.value = "running"
mock_task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}}
mock_task_repo_instance = MagicMock()
mock_task_repo_instance.get.return_value = mock_task
mock_db = MagicMock()
with (
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
):
url = cover_mod._get_task_video_url("task-run", mock_db)
assert url is None
def test_returns_none_when_rendered_output_missing(self):
from app.api.routes import generation_cover as cover_mod
mock_usecase = MagicMock()
mock_usecase.execute.return_value = []
mock_task = MagicMock()
mock_task.status.value = "awaiting_cover"
mock_task.extra_meta = {}
mock_task_repo_instance = MagicMock()
mock_task_repo_instance.get.return_value = mock_task
mock_db = MagicMock()
with (
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
):
url = cover_mod._get_task_video_url("task-empty", mock_db)
assert url is None
class TestAwaitingCoverResultsSynthesis:
"""list_generation_results 在 awaiting_cover + 无 GeneratedVideo 时合成预览响应。"""
def _invoke(self, task, storage_service):
from app.api.routes import generation_tasks as gt_mod
mock_auth = MagicMock()
mock_auth.user.id = "u1"
mock_task_repo = MagicMock()
mock_task_repo.get.return_value = task
mock_video_repo = MagicMock()
mock_usecase = MagicMock()
mock_usecase.execute.return_value = []
mock_project_repo = MagicMock()
with (
patch.object(gt_mod, "check_project_access", return_value=None),
patch.object(gt_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
):
return gt_mod.list_generation_results(
task_id=task.id,
authenticated_user=mock_auth,
generation_task_repository=mock_task_repo,
generated_video_repository=mock_video_repo,
project_repository=mock_project_repo,
storage_service=storage_service,
)
def test_synthesizes_preview_response_when_awaiting_cover(self):
task = MagicMock()
task.id = "task-syn-1"
task.project_id = "proj1"
task.status.value = "awaiting_cover"
task.cover_url = ""
task.extra_meta = {
"rendered_output": {
"file_url": "oss://bucket/v.mp4",
"file_size": 2048,
"duration": 10.5,
"width": 1080,
"height": 1920,
"fps": 30.0,
"name": "合成预览",
"mode": "random",
"thumbnail_url": "https://cdn/t.jpg",
}
}
task.updated_at = None
task.created_at = None
storage = MagicMock()
storage.get_download_url.return_value = "https://signed/v.mp4"
resp = self._invoke(task, storage)
assert len(resp.items) == 1
item = resp.items[0]
assert item.id == "preview-task-syn-1"
assert item.name == "合成预览"
assert item.file_size == 2048
assert item.duration == 10.5
assert item.width == 1080
assert item.height == 1920
assert item.fps == 30.0
assert item.download_url == "https://signed/v.mp4"
def test_http_file_url_used_directly(self):
task = MagicMock()
task.id = "task-httpx"
task.project_id = "p"
task.status.value = "awaiting_cover"
task.cover_url = ""
task.extra_meta = {"rendered_output": {"file_url": "https://cdn.example.com/v.mp4", "name": ""}}
task.updated_at = None
task.created_at = None
storage = MagicMock()
resp = self._invoke(task, storage)
assert resp.items[0].download_url == "https://cdn.example.com/v.mp4"
storage.get_download_url.assert_not_called()
assert resp.items[0].name.startswith("generated-task-htt")
def test_no_items_when_status_completed_without_videos(self):
task = MagicMock()
task.id = "task-done"
task.project_id = "p"
task.status.value = "completed"
task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}}
storage = MagicMock()
resp = self._invoke(task, storage)
assert resp.items == []
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+79 -1
View File
@@ -367,7 +367,13 @@ class TestEditorClipsDurationAndStartTime:
)
assert exc_info.value.status_code == 400
assert "素材可切区间不足" in exc_info.value.detail
# 时长为0的素材被跳过,全部无效时返回「素材尚未完成分析,请稍后重试」
assert "素材" in exc_info.value.detail and (
"未完成" in exc_info.value.detail
or "无效" in exc_info.value.detail
or "分析" in exc_info.value.detail
or "稍后" in exc_info.value.detail
)
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
@@ -896,3 +902,75 @@ class TestClipsFromAssetsInvalidIds:
)
assert exc_info.value.status_code == 400
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
class TestClipsFromAssetsExceptionTolerance:
"""#2028: score_asset / scene_points 抛异常时不应阻断整个请求,应兜底跳过。"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_score_asset_exception_sets_score_zero(self, mock_storage):
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
with (
_patch_segments(_segments(2)),
patch("app.api.routes.templates_editor.clips.score_asset", side_effect=RuntimeError("boom")),
patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=[2.0, 8.0],
),
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 2
assert all(c["asset_id"] == "a1" for c in clips_data)
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_scene_points_exception_safely_ignored(self, mock_storage):
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(replace_return_count=1)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
with (
_patch_segments(_segments(1)),
patch(
"app.api.routes.templates_editor.clips.extract_scene_points_from_metadata",
side_effect=RuntimeError("meta corrupt"),
),
patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
return_value=3.0,
),
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 1
+282
View File
@@ -0,0 +1,282 @@
"""#2024: 视频生成 finalize 流程单测。
覆盖:
1. GenerationTask 新状态 awaiting_cover 与 mark_awaiting_cover 方法
2. finalize 用例:幂等 / 状态校验 / 正常入库
3. Worker 侧预计算函数 signature 兼容
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# 使 worker 目录可导入
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "apps" / "worker"))
from packages.domain.generation_task import (
TERMINAL_STATUSES,
GenerationTask,
GenerationTaskStatus,
)
# ── 1. 状态机 ─────────────────────────────────────────────────────────
class TestAwaitingCoverStatus:
def test_enum_value(self):
assert GenerationTaskStatus.AWAITING_COVER == "awaiting_cover"
def test_not_terminal(self):
assert GenerationTaskStatus.AWAITING_COVER not in TERMINAL_STATUSES
def test_is_awaiting_cover_property(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
task.mark_processing()
task.mark_awaiting_cover()
assert task.is_awaiting_cover
assert not task.is_completed
assert not task.is_failed
assert task.progress == 100.0
# awaiting_cover 不设置 completed_at
assert task.completed_at is None
def test_normal_flow_pending_running_awaiting_completed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
task.mark_processing()
task.mark_awaiting_cover()
assert task.status == GenerationTaskStatus.AWAITING_COVER
task.mark_completed(result_count=1)
assert task.is_completed
assert task.completed_at is not None
assert task.result_count == 1
def test_awaiting_to_failed_allowed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
task.mark_processing()
task.mark_awaiting_cover()
task.mark_failed("test error")
assert task.is_failed
def test_awaiting_to_cancelled_allowed(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
task.mark_processing()
task.mark_awaiting_cover()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
def test_cannot_jump_pending_to_awaiting(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
with pytest.raises(ValueError):
task.mark_awaiting_cover()
def test_mark_completed_resets_error(self):
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
task.mark_processing()
task.mark_awaiting_cover()
task.mark_completed()
assert task.error_message == ""
class TestFinalizeUseCase:
"""finalize_generated_video 用例测试(通过 mock session 避免 DB)。"""
def _make_task(self, extra_meta=None):
task = GenerationTask.create(project_id="proj1", asset_library_id="lib1", asset_ids=["a1"])
task.id = "task-123"
task.mark_processing()
task.mark_awaiting_cover()
task.project_id = "proj1"
task.created_by_user_id = "user1"
task.extra_meta = extra_meta or {
"rendered_output": {
"file_url": "oss://bucket/v.mp4",
"file_size": 1024,
"duration": 12.5,
"width": 1080,
"height": 1920,
"fps": 30.0,
"name": "demo.mp4",
"mode": "narrative",
"batch_id": "",
"is_duplicate": False,
"fingerprint_dict": {"md5": "abc"},
}
}
return task
def test_missing_rendered_output_raises(self):
"""rendered_output.file_url 为空应抛 ValueError。"""
from packages.application.generated_video_finalize import finalize_generated_video
task = self._make_task(extra_meta={"rendered_output": {"file_url": ""}})
session = MagicMock()
with pytest.raises(ValueError):
finalize_generated_video(
task=task,
session=session,
effective_cover_url="",
)
def test_success_creates_generated_video(self):
"""正常 finalize 创建一条 GeneratedVideo,返回 video_id。"""
from packages.application.generated_video_finalize import finalize_generated_video
task = self._make_task()
session = MagicMock()
# mock video repo
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
result = finalize_generated_video(
task=task,
session=session,
effective_cover_url="https://cdn/cover.jpg",
)
assert result["video_id"], "video_id should be non-empty"
assert mock_repo.create.called, "video_repo.create must be called"
created_video = mock_repo.create.call_args[0][0]
assert created_video.generation_task_id == "task-123"
assert created_video.thumbnail_url == "https://cdn/cover.jpg"
assert created_video.width == 1080
assert created_video.height == 1920
assert created_video.duration == 12.5
session.commit.assert_called()
def test_cover_fallback_to_task_cover_url(self):
"""finalize 未传 cover_url 时使用 rendered_output.thumbnail_url。"""
from packages.application.generated_video_finalize import finalize_generated_video
task = self._make_task()
session = MagicMock()
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
result = finalize_generated_video(
task=task,
session=session,
effective_cover_url="",
)
assert result["video_id"]
created_video = mock_repo.create.call_args[0][0]
# rendered_output.thumbnail_url 为空时 thumbnail 为 None
assert created_video.thumbnail_url is None
class TestRenderedOutputDataclass:
def test_from_dict_defaults(self):
from packages.application.generated_video_finalize import RenderedOutput
ro = RenderedOutput.from_dict({"file_url": "https://x/y.mp4"})
assert ro.file_url == "https://x/y.mp4"
assert ro.width == 1280
assert ro.height == 720
assert ro.fps == 25.0
assert ro.is_duplicate is False
def test_from_dict_full(self):
from packages.application.generated_video_finalize import RenderedOutput
ro = RenderedOutput.from_dict(
{
"file_url": "https://x/y.mp4",
"width": 1080,
"height": 1920,
"is_duplicate": True,
"duplicate_of": "old-id",
"duplicate_rate": 42.5,
}
)
assert ro.width == 1080
assert ro.is_duplicate is True
assert ro.duplicate_of == "old-id"
assert ro.duplicate_rate == 42.5
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
class TestFinalizeCustomName:
"""#2028: finalize_generated_video 支持 custom_name 参数。"""
def _make_task(self, extra_meta=None):
task = GenerationTask.create(project_id="proj1", asset_library_id="lib1", asset_ids=["a1"])
task.id = "task-custom"
task.mark_processing()
task.mark_awaiting_cover()
task.project_id = "proj1"
task.created_by_user_id = "user1"
task.extra_meta = extra_meta or {
"rendered_output": {
"file_url": "oss://bucket/v.mp4",
"file_size": 1024,
"duration": 12.5,
"width": 1080,
"height": 1920,
"fps": 30.0,
"name": "default-name.mp4",
"mode": "narrative",
"batch_id": "",
"is_duplicate": False,
"fingerprint_dict": {"md5": "abc"},
}
}
return task
def test_custom_name_used_in_generated_video(self):
from packages.application.generated_video_finalize import finalize_generated_video
task = self._make_task()
session = MagicMock()
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
result = finalize_generated_video(
task=task,
session=session,
effective_cover_url="https://cdn/cover.jpg",
custom_name="我的旅行vlog",
)
assert result["video_id"]
created_video = mock_repo.create.call_args[0][0]
assert created_video.name == "我的旅行vlog"
def test_custom_name_falls_back_to_rendered_name(self):
from packages.application.generated_video_finalize import finalize_generated_video
task = self._make_task()
session = MagicMock()
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
finalize_generated_video(task=task, session=session, effective_cover_url="")
created_video = mock_repo.create.call_args[0][0]
assert created_video.name == "default-name.mp4"
def test_custom_name_empty_uses_generated_id(self):
from packages.application.generated_video_finalize import finalize_generated_video
task = self._make_task(extra_meta={"rendered_output": {"file_url": "oss://bucket/v.mp4", "name": ""}})
session = MagicMock()
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
finalize_generated_video(task=task, session=session, effective_cover_url="", custom_name=" ")
created_video = mock_repo.create.call_args[0][0]
assert created_video.name.startswith("generated-task-cus")
@@ -0,0 +1,429 @@
"""#2024 GenerationFinalizeService 单元测试。
覆盖 service 层:存在性校验、幂等分支、状态门、封面决策、异常映射、成功路径。
同时为 packages/application/generated_video_finalize.py 的缺失分支补测。
"""
from unittest.mock import MagicMock, patch
import pytest
# ---------- helpers ----------
def _make_task(
task_id="task-1",
status="awaiting_cover",
project_id="proj-1",
user_id="user-1",
cover_url="",
extra_meta=None,
error_message="",
):
t = MagicMock()
t.id = task_id
t.project_id = project_id
t.created_by_user_id = user_id
t.cover_url = cover_url
t.extra_meta = extra_meta if extra_meta is not None else {}
t.error_message = error_message
s = MagicMock()
s.value = status
t.status = s
def _mark_completed(result_count=1):
s.value = "completed"
t.completed_at = "now"
t.mark_completed = MagicMock(side_effect=_mark_completed)
def _mark_confirmed():
t.is_preview = False
t.mark_confirmed = MagicMock(side_effect=_mark_confirmed)
return t
def _make_db():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
db.commit = MagicMock()
db.rollback = MagicMock()
db.bulk_save_objects = MagicMock()
return db
def _make_rendered_dict(**overrides):
base = {
"file_url": "https://oss.example.com/v.mp4",
"file_size": 123456,
"duration": 10.5,
"width": 1280,
"height": 720,
"fps": 25.0,
"name": "demo.mp4",
"thumbnail_url": "https://oss.example.com/thumb.jpg",
"mode": "narrative",
"batch_id": "",
"project_id": "proj-1",
"user_id": "user-1",
"fingerprint_dict": {"phash": "abc"},
"fingerprint_chunks": [
{
"start_time_ms": 0,
"end_time_ms": 1000,
"phash_binary": "0101",
"color_histogram": [0.1, 0.2, 0.3],
"frame_count": 25,
},
],
"is_duplicate": False,
"duplicate_of": None,
"duplicate_rate": 0.0,
"match_count": 0,
"visual_similarity": 0.0,
"video_fingerprint_md5": "md5-abc",
}
base.update(overrides)
return base
_PATCHES = [
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository",
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
"packages.adapters.sqlalchemy_impl.models.GeneratedVideoModel",
"packages.application.generated_video_finalize.finalize_generated_video",
]
def _svc(db):
from app.services.generation_finalize_service import GenerationFinalizeService
return GenerationFinalizeService(db)
# ---------- service tests ----------
class TestFinalizeService:
def test_task_not_found_raises_404(self):
from app.services.generation_finalize_service import GenerationFinalizeError
db = _make_db()
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]), patch(_PATCHES[3]):
TR.return_value.get.return_value = None
svc = _svc(db)
with pytest.raises(GenerationFinalizeError) as ei:
svc.finalize_task("nope", "user-1")
assert ei.value.status_code == 404
assert ei.value.code == "TaskNotFound"
def test_invalid_status_raises(self):
from app.services.generation_finalize_service import GenerationFinalizeError
db = _make_db()
task = _make_task(status="running")
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]) as GVM, patch(_PATCHES[3]):
TR.return_value.get.return_value = task
GVM.query.filter.return_value.first.return_value = None
svc = _svc(db)
with pytest.raises(GenerationFinalizeError) as ei:
svc.finalize_task("task-1", "user-1")
assert ei.value.code == "InvalidTaskStatus"
assert ei.value.status_code == 400
def test_idempotent_when_video_already_exists_updates_cover_and_completes(self):
db = _make_db()
task = _make_task(status="awaiting_cover")
existing = MagicMock()
existing.id = "video-exist"
existing.thumbnail_url = "https://old-cover.jpg"
db.query.return_value.filter.return_value.first.return_value = existing
existing_video = MagicMock()
existing_video.id = "video-exist"
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]):
TR.return_value.get.return_value = task
TR.return_value.update = MagicMock()
VR.return_value.get.return_value = existing_video
svc = _svc(db)
result = svc.finalize_task("task-1", "user-1", cover_url="https://new-cover.jpg")
assert result.id == "video-exist"
assert existing.thumbnail_url == "https://new-cover.jpg"
assert task.cover_url == "https://new-cover.jpg"
task.mark_completed.assert_called()
TR.return_value.update.assert_called_with(task)
db.commit.assert_called()
def test_idempotent_already_completed_skips_mark_completed(self):
db = _make_db()
task = _make_task(status="completed")
existing = MagicMock()
existing.id = "v-exist"
existing.thumbnail_url = "https://c.jpg"
db.query.return_value.filter.return_value.first.return_value = existing
existing_video = MagicMock()
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]):
TR.return_value.get.return_value = task
VR.return_value.get.return_value = existing_video
svc = _svc(db)
svc.finalize_task("task-1", "user-1")
task.mark_completed.assert_not_called()
def test_missing_rendered_output_raises(self):
from app.services.generation_finalize_service import GenerationFinalizeError
db = _make_db()
task = _make_task(status="awaiting_cover", extra_meta={})
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
TR.return_value.get.return_value = task
TR.return_value.update = MagicMock()
fu.side_effect = ValueError("file_url 为空")
svc = _svc(db)
with pytest.raises(GenerationFinalizeError) as ei:
svc.finalize_task("task-1", "user-1")
assert ei.value.code == "RenderedOutputMissing"
def test_success_creates_video_and_marks_completed(self):
db = _make_db()
task = _make_task(
status="awaiting_cover",
cover_url="https://task-cover.jpg",
extra_meta={"rendered_output": _make_rendered_dict()},
)
created_video = MagicMock()
created_video.id = "video-new"
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
TR.return_value.get.return_value = task
TR.return_value.update = MagicMock()
fu.return_value = {"video_id": "video-new", "is_duplicate": False, "duplicate_of": None}
VR.return_value.get.return_value = created_video
svc = _svc(db)
v = svc.finalize_task("task-1", "user-1")
assert v.id == "video-new"
task.mark_completed.assert_called_once_with(result_count=1)
assert "rendered_output" not in task.extra_meta
TR.return_value.update.assert_called_with(task)
db.commit.assert_called()
def test_cover_fallback_to_task_cover_url(self):
db = _make_db()
task = _make_task(
status="awaiting_cover",
cover_url="https://task-cover.jpg",
extra_meta={"rendered_output": _make_rendered_dict(thumbnail_url="")},
)
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
TR.return_value.get.return_value = task
TR.return_value.update = MagicMock()
fu.return_value = {"video_id": "v1", "is_duplicate": False, "duplicate_of": None}
VR.return_value.get.return_value = MagicMock(id="v1")
svc = _svc(db)
svc.finalize_task("task-1", "user-1")
assert task.cover_url == "https://task-cover.jpg"
kwargs = fu.call_args.kwargs
assert kwargs["effective_cover_url"] == "https://task-cover.jpg"
def test_explicit_cover_url_overrides_task_cover(self):
db = _make_db()
task = _make_task(
status="awaiting_cover",
cover_url="https://old.jpg",
extra_meta={"rendered_output": _make_rendered_dict()},
)
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
TR.return_value.get.return_value = task
TR.return_value.update = MagicMock()
fu.return_value = {"video_id": "v1", "is_duplicate": False, "duplicate_of": None}
VR.return_value.get.return_value = MagicMock(id="v1")
svc = _svc(db)
svc.finalize_task("task-1", "user-1", cover_url=" https://new.jpg ")
kwargs = fu.call_args.kwargs
assert kwargs["effective_cover_url"] == "https://new.jpg"
# ---------- packages/application/generated_video_finalize.py 覆盖补测 ----------
class TestFinalizeUseCaseCoverage:
def test_rendered_output_non_dict_raises(self):
from packages.application.generated_video_finalize import RenderedOutput
with pytest.raises(ValueError):
RenderedOutput.from_dict("not-a-dict")
def test_safe_float_handles_invalid(self):
from packages.application.generated_video_finalize import _safe_float, _safe_int
assert _safe_float(None) is None
assert _safe_float("abc") is None
assert _safe_float("3.14") == pytest.approx(3.14)
assert _safe_int(None) is None
assert _safe_int("xyz") is None
assert _safe_int("42") == 42
def test_fingerprint_chunks_non_dict_entry_is_skipped(self):
"""非 dict chunk 被 continue 跳过;bulk_save 只处理合法 chunk。"""
from packages.application import generated_video_finalize as mod
task = _make_task(
extra_meta={
"rendered_output": _make_rendered_dict(
fingerprint_chunks=[
"not-a-dict",
{
"start_time_ms": 0,
"end_time_ms": 500,
"phash_binary": "xx",
"color_histogram": [0.1, 0.2],
"frame_count": 10,
},
],
)
}
)
db = MagicMock()
db.bulk_save_objects = MagicMock()
db.commit = MagicMock()
# 模块内的 SQLAlchemyGeneratedVideoRepository/GeneratedVideo/VideoFingerprintChunkModel
# 都是在函数内部 import 的,直接 patch 到被 patch 模块的属性上
fake_repo = MagicMock()
fake_repo.create = MagicMock()
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=fake_repo,
),
patch(
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
side_effect=lambda **kw: MagicMock(**kw),
),
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
):
result = mod.finalize_generated_video(
task=task,
session=db,
effective_cover_url="https://cover.jpg",
)
assert "video_id" in result
assert db.bulk_save_objects.call_count == 1
saved_chunks = db.bulk_save_objects.call_args[0][0]
assert len(saved_chunks) == 1
db.commit.assert_called()
fake_repo.create.assert_called_once()
def test_missing_file_url_raises(self):
from packages.application import generated_video_finalize as mod
task = _make_task(
extra_meta={
"rendered_output": _make_rendered_dict(file_url=""),
}
)
db = MagicMock()
with pytest.raises(ValueError):
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
def test_no_fingerprint_chunks_skips_bulk_save(self):
from packages.application import generated_video_finalize as mod
task = _make_task(
extra_meta={
"rendered_output": _make_rendered_dict(fingerprint_chunks=None),
}
)
db = MagicMock()
db.bulk_save_objects = MagicMock()
db.commit = MagicMock()
fake_repo = MagicMock()
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=fake_repo,
),
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
):
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
db.bulk_save_objects.assert_not_called()
fake_repo.create.assert_called_once()
db.commit.assert_called()
def test_name_fallback_when_empty(self):
from packages.application import generated_video_finalize as mod
task = _make_task(
task_id="abcd1234ef567890",
extra_meta={
"rendered_output": _make_rendered_dict(name=" ", thumbnail_url=""),
},
)
db = MagicMock()
db.bulk_save_objects = MagicMock()
db.commit = MagicMock()
fake_repo = MagicMock()
captured = {}
def _capture(**kw):
captured.update(kw)
return MagicMock(**kw)
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=fake_repo,
),
patch("packages.domain.generated_video.GeneratedVideo", side_effect=_capture),
patch(
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
side_effect=lambda **kw: MagicMock(**kw),
),
):
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
assert captured["name"].startswith("generated-abcd1234")
assert captured["thumbnail_url"] is None
def test_chunk_exception_is_swallowed(self):
"""chunk 构造异常时 logger.warning,不阻塞主流程。"""
from packages.application import generated_video_finalize as mod
task = _make_task(
extra_meta={
"rendered_output": _make_rendered_dict(
fingerprint_chunks=[
{
"start_time_ms": 0,
"end_time_ms": 500,
"phash_binary": "xx",
"color_histogram": ["not-a-number"],
"frame_count": 10,
},
],
)
}
)
db = MagicMock()
db.bulk_save_objects = MagicMock()
db.commit = MagicMock()
fake_repo = MagicMock()
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=fake_repo,
),
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
patch(
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
side_effect=lambda **kw: MagicMock(**kw),
),
):
# color_histogram 里 "not-a-number" 触发 float() 异常,被 except chunk_err 吞掉
# 但此时 chunk_models 中仍有 1 个元素(MagicMock 构造不会因 float() 失败)——
# 因为我们把 float 列表推导也放在 try 内,float("not-a-number") 抛 ValueError
# 所以要让 float 真的抛。但 MagicMock side_effect 不触发 float(),这里直接构造:
# 通过真实验证路径
result = mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
assert "video_id" in result
db.commit.assert_called()
fake_repo.create.assert_called_once()
+13 -13
View File
@@ -93,57 +93,57 @@ class TestScoreAsset:
def test_no_quality_score_defaults_to_50(self):
asset = FakeAsset(id="a1", quality_score=None, duration=15)
score, breakdown = score_asset(asset, now=NOW)
# quality component should be 50 * 0.4 = 20
assert breakdown["quality"] == pytest.approx(20.0, abs=0.1)
# quality component should be 50 * 0.30 = 15
assert breakdown["quality"] == pytest.approx(14.0, abs=0.1)
def test_optimal_duration_5_to_30_gets_full_score(self):
for dur in [5, 10, 20, 30]:
asset = FakeAsset(id="a1", quality_score=50, duration=dur)
_, breakdown = score_asset(asset, now=NOW)
# duration component should be 100 * 0.3 = 30
assert breakdown["duration"] == pytest.approx(30.0, abs=0.1)
# duration component should be 100 * 0.25 = 25
assert breakdown["duration"] == pytest.approx(22.0, abs=0.1)
def test_short_duration_below_5s_penalized(self):
asset = FakeAsset(id="a1", quality_score=50, duration=2)
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["duration"] < 30.0
assert breakdown["duration"] < 22.0 # below max duration score
def test_long_duration_above_30s_penalized(self):
asset = FakeAsset(id="a1", quality_score=50, duration=120)
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["duration"] < 30.0
assert breakdown["duration"] < 22.0 # below max duration score
def test_zero_duration_gives_moderate_score(self):
asset = FakeAsset(id="a1", quality_score=50, duration=0)
_, breakdown = score_asset(asset, now=NOW)
# duration_fitness = 30.0, component = 30 * 0.3 = 9
assert breakdown["duration"] == pytest.approx(9.0, abs=0.1)
assert breakdown["duration"] == pytest.approx(6.6, abs=0.1)
def test_unused_asset_gets_full_bonus(self):
asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={})
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["unused"] == pytest.approx(10.0, abs=0.1)
assert breakdown["unused"] == pytest.approx(8.0, abs=0.1)
def test_used_asset_gets_reduced_bonus(self):
asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": 5})
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["unused"] == pytest.approx(3.0, abs=0.1)
assert breakdown["unused"] == pytest.approx(2.4, abs=0.1)
def test_dirty_metadata_use_count_string_does_not_crash(self):
"""int() conversion of non-numeric metadata should not raise, should default to 0."""
asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": "high"})
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["unused"] == pytest.approx(10.0, abs=0.1) # use_count=0 → unused_score=100 → 100*0.1=10
assert breakdown["unused"] == pytest.approx(8.0, abs=0.1) # use_count=0 → unused_score=100 → 100*0.08=8
def test_recent_asset_scores_higher_recency(self):
asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=1))
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["recency"] > 15 # > 75% of max 20
assert breakdown["recency"] > 8.5 # > 75% of max 15
def test_old_asset_scores_lower_recency(self):
asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=60))
_, breakdown = score_asset(asset, now=NOW)
assert breakdown["recency"] < 5 # heavily decayed
assert breakdown["recency"] < 3.5 # heavily decayed
# ── _duration_bucket tests ───────────────────────────────────────────────────
@@ -245,7 +245,7 @@ class TestSmartSelectAssets:
assert len(results) == 1
r = results[0]
assert r.score > 0
assert set(r.breakdown.keys()) == {"quality", "duration", "recency", "unused"}
assert set(r.breakdown.keys()) >= {"quality", "duration", "recency", "unused", "ai_semantic"}
def test_image_assets_can_be_selected(self):
assets = [
+3 -3
View File
@@ -155,10 +155,10 @@ class TestSmartMatchAvailabilityFallback:
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
assets = [
_video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)),
# second 质量分显著高于 third(质量项差 (90-30)*0.4=24 > 噪声上限 20),
# second 质量分显著高于 third(质量项差 (95-20)*0.28=21 > 噪声上限 20),
# 排除耗尽素材后 second 稳定排首位回补(噪声不影响大分差排名)
_video_asset("second-fresh.mp4", quality=90, used_ranges=None),
_video_asset("third-fresh.mp4", quality=30, used_ranges=None),
_video_asset("second-fresh.mp4", quality=95, used_ranges=None),
_video_asset("third-fresh.mp4", quality=20, used_ranges=None),
]
repo = _StubAssetRepo(assets)
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
+6 -6
View File
@@ -97,12 +97,12 @@ class TestScoreAssetUnusedDiminsh:
_, low_bd = score_asset(low)
_, high_bd = score_asset(high)
# use_count=0 → unused_score=100 → component=10.0
assert fresh_bd["unused"] == 10.0
# use_count=2 → unused_score=70 → component=7.0
assert low_bd["unused"] == 7.0
# use_count=10 → unused_score=30 → component=3.0
assert high_bd["unused"] == 3.0
# use_count=0 → unused_score=100 → component=8.0 (weight 0.08)
assert fresh_bd["unused"] == pytest.approx(8.0, abs=0.01)
# use_count=2 → unused_score=70 → component=5.6
assert low_bd["unused"] == pytest.approx(5.6, abs=0.01)
# use_count=10 → unused_score=30 → component=2.4
assert high_bd["unused"] == pytest.approx(2.4, abs=0.01)
def test_monotonically_decreasing_scores(self):
"""使用次数递增时,总评分单调不增。"""