Compare commits

...

8 Commits

Author SHA1 Message Date
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
37 changed files with 3826 additions and 372 deletions
+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)
+122 -3
View File
@@ -31,6 +31,8 @@ from app.schemas.generation_task import (
BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest,
FinalizeGenerationRequest,
FinalizeGenerationResponse,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
@@ -891,8 +893,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 +915,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 +946,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 +1015,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 +1123,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):
"""创建生成任务请求。
@@ -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
+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
}
@@ -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)
}
}, [])
@@ -963,6 +993,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 +1017,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" && (
+6 -5
View File
@@ -21,7 +21,7 @@ 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"
@@ -411,7 +411,7 @@ const GeneratePage: React.FC = () => {
/* ── 最终成片(单视频) ── */
const finalVideo = generatedVideos[0]
/* ── Step5 完成:调用 confirm 入库 + 跳转 ── */
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
const handleFinish = useCallback(async () => {
if (finishing) return
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
@@ -436,12 +436,12 @@ const GeneratePage: React.FC = () => {
? [finalVideo.generation_task_id]
: []
// 单视频/批量:为每个任务调用 confirm(传入封面)
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
if (isBatch && previewCovers.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return confirmGeneration(taskId, {
return finalizeGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
})
@@ -449,11 +449,12 @@ const GeneratePage: React.FC = () => {
)
} else if (finalVideo?.generation_task_id) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await confirmGeneration(finalVideo.generation_task_id, {
await finalizeGeneration(finalVideo.generation_task_id, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
}
hide()
message.success("已保存到视频库")
navigate("/app/products")
@@ -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" && (
@@ -74,7 +74,10 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const completedVideos = props.generatedVideos.filter(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
)
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
@@ -1,8 +1,17 @@
import React, { useState } from "react"
import type { CoverTemplate } from "../../types/cover"
import React, { useState, useCallback, useRef, useEffect } from "react"
import { Slider, Switch, Select, InputNumber, Input } from "antd"
import type {
CoverTemplate,
CoverEditorConfig,
TextStyleConfig,
TextDirection,
StrokeStyle,
} from "../../types/cover"
import { DEFAULT_EDITOR_CONFIG, PRESET_FONTS, SYSTEM_FONTS, ALL_FONTS } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
/* ── Props ── */
interface CoverEditorModalProps {
open: boolean
onClose: () => void
@@ -10,53 +19,420 @@ interface CoverEditorModalProps {
onSave: (template: CoverTemplate) => void
}
interface SectionState {
basic: boolean
portrait: boolean
background: boolean
title: boolean
subtitle: boolean
mask: boolean
/* ── Section expand/collapse keys ── */
type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask"
/* ── Color picker sub-component ── */
const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({
value,
onChange,
}) => (
<div className="xx-ce-color-picker">
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
maxLength={7}
className="xx-ce-color-hex"
/>
</div>
)
/* ── Position pair sub-component ── */
const PositionPair: React.FC<{
x: number
y: number
onChange: (pos: { x: number; y: number }) => void
}> = ({ x, y, onChange }) => (
<div className="xx-ce-position">
<InputNumber
size="small"
value={x}
step={1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x: v ?? 0, y })}
style={{ width: "100%" }}
/>
<InputNumber
size="small"
value={y}
step={1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x, y: v ?? 0 })}
style={{ width: "100%" }}
/>
</div>
)
/* ── Font Select options ── */
const fontOptions = [
...PRESET_FONTS.map((f) => ({
label: (
<span>
<span className="xx-ce-font-dot xx-ce-font-dot--preset" />
<span style={{ fontFamily: f.family }}>{f.name}</span>
</span>
),
value: f.name,
})),
...SYSTEM_FONTS.map((f) => ({
label: (
<span>
<span className="xx-ce-font-dot xx-ce-font-dot--system" />
<span style={{ fontFamily: f.family }}>{f.name}</span>
</span>
),
value: f.name,
})),
]
/* ── Find font family string from name ── */
const getFontFamily = (name: string): string => {
const found = ALL_FONTS.find((f) => f.name === name)
return found ? found.family : "sans-serif"
}
/* ── Text style panel (shared between title & subtitle) ── */
const TextStylePanel: React.FC<{
config: TextStyleConfig
onChange: (c: TextStyleConfig) => void
placeholder: string
}> = ({ config, onChange, placeholder }) => {
const upd = <K extends keyof TextStyleConfig>(key: K, val: TextStyleConfig[K]) =>
onChange({ ...config, [key]: val })
return (
<div className="xx-ce-text-panel">
{/* 文字内容 - 改为可输入 */}
<div className="xx-ce-row">
<label className="xx-ce-label">文字内容</label>
<Input.TextArea
value={config.text}
onChange={(e) => upd("text", e.target.value)}
placeholder={placeholder}
autoSize={{ minRows: 1, maxRows: 3 }}
size="small"
maxLength={50}
showCount
/>
</div>
{/* 字体 */}
<div className="xx-ce-row">
<label className="xx-ce-label">字体</label>
<Select
value={config.fontFamily}
onChange={(v) => upd("fontFamily", v)}
options={fontOptions}
style={{ width: "100%" }}
popupClassName="xx-ce-font-select-dropdown"
size="small"
/>
</div>
{/* 字号 */}
<div className="xx-ce-row">
<label className="xx-ce-label">字号: {config.fontSize}px</label>
<Slider
min={20}
max={200}
step={1}
value={config.fontSize}
onChange={(v) => upd("fontSize", v)}
/>
</div>
{/* 字重 */}
<div className="xx-ce-row">
<label className="xx-ce-label">字重: {config.fontWeight}</label>
<Slider
min={100}
max={1000}
step={100}
value={config.fontWeight}
onChange={(v) => upd("fontWeight", v)}
marks={{ 400: "常规", 700: "粗", 900: "黑" }}
/>
</div>
{/* 文字方向 */}
<div className="xx-ce-row">
<label className="xx-ce-label">文字方向</label>
<div className="xx-ce-radio-group">
{(["horizontal", "vertical"] as TextDirection[]).map((d) => (
<button
key={d}
className={`xx-ce-radio-btn ${config.direction === d ? "active" : ""}`}
onClick={() => upd("direction", d)}
type="button"
>
{d === "horizontal" ? "横排" : "竖排"}
</button>
))}
</div>
</div>
{/* 每行字数 */}
<div className="xx-ce-row">
<label className="xx-ce-label">每行字数: {config.charsPerLine}</label>
<Slider
min={1}
max={20}
step={1}
value={config.charsPerLine}
onChange={(v) => upd("charsPerLine", v)}
/>
</div>
{/* 字符间距 */}
<div className="xx-ce-row">
<label className="xx-ce-label">字符间距: {config.letterSpacing}px</label>
<Slider
min={-10}
max={50}
step={1}
value={config.letterSpacing}
onChange={(v) => upd("letterSpacing", v)}
/>
</div>
{/* 行间距 */}
<div className="xx-ce-row">
<label className="xx-ce-label">行间距: {config.lineHeight}px</label>
<Slider
min={0}
max={200}
step={1}
value={config.lineHeight}
onChange={(v) => upd("lineHeight", v)}
/>
</div>
{/* 颜色 */}
<div className="xx-ce-row">
<label className="xx-ce-label">文字颜色</label>
<ColorPicker value={config.color} onChange={(v) => upd("color", v)} />
</div>
{/* 描边 */}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span>文字描边</span>
<Switch
size="small"
checked={config.strokeWidth > 0}
onChange={(v) => upd("strokeWidth", v ? 3 : 0)}
/>
</div>
{config.strokeWidth > 0 && (
<>
<div className="xx-ce-row">
<label className="xx-ce-label">描边颜色</label>
<ColorPicker value={config.strokeColor} onChange={(v) => upd("strokeColor", v)} />
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">描边粗细: {config.strokeWidth}px</label>
<Slider
min={0}
max={20}
step={1}
value={config.strokeWidth}
onChange={(v) => upd("strokeWidth", v)}
/>
</div>
</>
)}
</div>
{/* 位置 */}
<div className="xx-ce-row">
<label className="xx-ce-label">位置(X/Y %)</label>
<PositionPair
x={config.position.x}
y={config.position.y}
onChange={(pos) => upd("position", pos)}
/>
</div>
{/* 旋转角度 */}
<div className="xx-ce-row">
<label className="xx-ce-label">旋转角度: {config.rotation}°</label>
<Slider
min={-180}
max={180}
step={1}
value={config.rotation}
onChange={(v) => upd("rotation", v)}
/>
</div>
</div>
)
}
/* ══════════════ Main Component ══════════════ */
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
const [name, setName] = useState(template?.name || "")
const [sections, setSections] = useState<SectionState>({
const initCfg = template?.config ?? DEFAULT_EDITOR_CONFIG
const [name, setName] = useState(template?.name ?? "")
const [cfg, setCfg] = useState<CoverEditorConfig>(() => ({ ...initCfg }))
const [sections, setSections] = useState<Record<SectionKey, boolean>>({
basic: true,
portrait: false,
background: false,
background: true,
title: true,
subtitle: true,
subtitle: false,
mask: false,
})
const fileRef = useRef<HTMLInputElement>(null)
const bgFileRef = useRef<HTMLInputElement>(null)
const portraitFileRef = useRef<HTMLInputElement>(null)
const [bgImageUrl, setBgImageUrl] = useState<string>(initCfg.backgroundImage || "")
const [portraitImageUrl, setPortraitImageUrl] = useState<string>(initCfg.portraitImage || "")
const toggleSection = (key: keyof SectionState) => {
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
// reset state when modal opens with a new template
useEffect(() => {
if (open) {
const c = template?.config ?? DEFAULT_EDITOR_CONFIG
setCfg({ ...c })
setName(template?.name ?? "")
setBgImageUrl(c.backgroundImage || "")
setPortraitImageUrl(c.portraitImage || "")
setSections({
basic: true,
portrait: false,
background: true,
title: true,
subtitle: false,
mask: false,
})
}
}, [open, template])
const upd = useCallback(
<K extends keyof CoverEditorConfig>(key: K, val: CoverEditorConfig[K]) =>
setCfg((prev) => ({ ...prev, [key]: val })),
[],
)
const updTitle = useCallback(
(c: TextStyleConfig) => setCfg((prev) => ({ ...prev, title: c })),
[],
)
const updSubtitle = useCallback(
(c: TextStyleConfig) => setCfg((prev) => ({ ...prev, subtitle: c })),
[],
)
const toggle = (key: SectionKey) => setSections((p) => ({ ...p, [key]: !p[key] }))
const handleFile = (
file: File | undefined,
setter: (url: string) => void,
cfgKey: "backgroundImage" | "portraitImage",
) => {
if (!file) return
const url = URL.createObjectURL(file)
setter(url)
upd(cfgKey, url)
}
const handleSave = () => {
if (!template) return
onSave({ ...template, name })
if (!name.trim()) {
// antd message unavailable here, use a simple alert fallback; better import
// but since we don't import message in this file, guard with default
setName(name.trim() || "我的封面模板")
}
const finalName = name.trim() || "我的封面模板"
const result: CoverTemplate = template
? { ...template, name: finalName, config: cfg }
: {
id: "",
name: finalName,
is_system: false,
thumbnail_url: "",
created_at: "",
config: cfg,
}
onSave(result)
onClose()
}
/* ── canvas helpers (scale from 1200px design to 225px preview) ── */
const PREVIEW_W = 225
const DESIGN_W = 1200
const SCALE = PREVIEW_W / DESIGN_W // 0.1875
const s = (px: number) => Math.round(px * SCALE * 100) / 100
const renderTextStyle = (tc: TextStyleConfig): React.CSSProperties => {
// wrap text by charsPerLine
const rawText = tc.text || ""
const lines: string[] = []
if (tc.direction === "vertical") {
lines.push(rawText)
} else {
for (let i = 0; i < rawText.length; i += Math.max(1, tc.charsPerLine)) {
lines.push(rawText.slice(i, i + Math.max(1, tc.charsPerLine)))
}
}
// store rendered as lines via data attribute; for JSX we'll render outside style
void lines
const style: React.CSSProperties = {
position: "absolute",
left: `${tc.position.x}%`,
top: `${tc.position.y}%`,
transform: `translate(-50%, -50%) rotate(${tc.rotation}deg)`,
fontFamily: getFontFamily(tc.fontFamily),
fontSize: s(tc.fontSize),
fontWeight: tc.fontWeight,
color: tc.color,
letterSpacing: s(tc.letterSpacing),
lineHeight: s(tc.lineHeight) > 0 ? `${s(tc.lineHeight)}px` : undefined,
WebkitTextStroke:
tc.strokeWidth > 0 ? `${Math.max(0.5, s(tc.strokeWidth))}px ${tc.strokeColor}` : undefined,
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "pre",
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
zIndex: 3,
textAlign: "center",
userSelect: "none",
}
if (tc.shadows.length > 0) {
style.textShadow = tc.shadows
.map((sh) => `${s(sh.offsetX)}px ${s(sh.offsetY)}px ${s(sh.blur)}px ${sh.color}`)
.join(", ")
}
return style
}
const renderTextLines = (tc: TextStyleConfig) => {
const raw = tc.text || ""
if (tc.direction === "vertical") return raw
const per = Math.max(1, tc.charsPerLine)
const lines: string[] = []
for (let i = 0; i < raw.length; i += per) lines.push(raw.slice(i, i + per))
return lines.join("\n")
}
return (
<Modal
open={open}
onCancel={onClose}
width={1000}
width={1080}
title="自定义封面编辑器"
centered
footer={null}
>
<div className="xx-cover-editor-header">
{/* ── Header ── */}
<div className="xx-ce-header">
<input
className="xx-cover-editor-name-input"
className="xx-ce-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入模板名称"
placeholder="输入模板名称(如:我的爆款封面)"
/>
<div className="xx-cover-editor-header-actions">
<div className="xx-ce-header-actions">
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
@@ -66,56 +442,469 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
</div>
</div>
<div className="xx-cover-editor-layout">
{/* 左侧折叠面板 */}
<div className="xx-cover-editor-left">
{[
{ key: "basic" as const, label: "基础设置" },
{ key: "portrait" as const, label: "人像设置" },
{ key: "background" as const, label: "背景设置", toggle: true },
{ key: "title" as const, label: "主标题" },
{ key: "subtitle" as const, label: "副标题" },
{ key: "mask" as const, label: "蒙版", toggle: true },
].map((item) => (
<div key={item.key} className="xx-cover-editor-section">
<div
className="xx-cover-editor-section-header"
onClick={() => toggleSection(item.key)}
>
<span>{item.label}</span>
<span>{sections[item.key] ? "▾" : "▸"}</span>
</div>
{sections[item.key] && (
<div className="xx-cover-editor-section-body">
{item.toggle ? (
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input type="checkbox" defaultChecked={false} />
已开启
</label>
) : (
<span style={{ color: "var(--text-tertiary)" }}>暂无配置项</span>
{/* ── Layout ── */}
<div className="xx-ce-layout">
{/* ── Left Panel ── */}
<div className="xx-ce-left">
{/* 1. 基础设置 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("basic")}>
<span>🔧 基础设置</span>
<span>{sections.basic ? "▾" : "▸"}</span>
</div>
{sections.basic && (
<div className="xx-ce-section-body">
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span>文字自动换行</span>
<Switch
size="small"
checked={cfg.autoSplitEnabled}
onChange={(v) => upd("autoSplitEnabled", v)}
/>
</div>
{cfg.autoSplitEnabled && (
<>
<div className="xx-ce-row">
<label className="xx-ce-label">主标题每行字数: {cfg.titleMaxChars}</label>
<Slider
min={2}
max={15}
step={1}
value={cfg.titleMaxChars}
onChange={(v) => upd("titleMaxChars", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">
副标题每行字数: {cfg.subtitleMaxChars}
</label>
<Slider
min={2}
max={20}
step={1}
value={cfg.subtitleMaxChars}
onChange={(v) => upd("subtitleMaxChars", v)}
/>
</div>
</>
)}
</div>
)}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span>背景模糊</span>
<Switch
size="small"
checked={cfg.blurEnabled}
onChange={(v) => upd("blurEnabled", v)}
/>
</div>
{cfg.blurEnabled && (
<div className="xx-ce-row">
<label className="xx-ce-label">模糊度: {cfg.blurAmount}</label>
<Slider
min={0}
max={20}
step={1}
value={cfg.blurAmount}
onChange={(v) => upd("blurAmount", v)}
/>
</div>
)}
</div>
</div>
)}
</div>
{/* 2. 背景 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("background")}>
<span>🎨 背景设置</span>
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={cfg.backgroundEnabled}
onChange={(v) => upd("backgroundEnabled", v)}
/>
</div>
</div>
))}
{sections.background && cfg.backgroundEnabled && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">背景图片</label>
<div className="xx-ce-file-row">
<input
className="xx-ce-file-name"
readOnly
value={bgImageUrl ? "已选择图片" : "未选择图片"}
/>
<button
className="xx-ce-file-btn"
type="button"
onClick={() => bgFileRef.current?.click()}
>
上传
</button>
{bgImageUrl && (
<button
className="xx-ce-file-btn"
type="button"
onClick={() => {
setBgImageUrl("")
upd("backgroundImage", "")
}}
>
清除
</button>
)}
<input
ref={bgFileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) =>
handleFile(e.target.files?.[0], setBgImageUrl, "backgroundImage")
}
/>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">背景颜色(无图片时使用)</label>
<ColorPicker
value={cfg.backgroundColor || "#1a1a2e"}
onChange={(v) => upd("backgroundColor", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">大小: {cfg.backgroundSize}%</label>
<Slider
min={50}
max={150}
step={1}
value={cfg.backgroundSize}
onChange={(v) => upd("backgroundSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">位置(X/Y %)</label>
<PositionPair
x={cfg.backgroundPosition.x}
y={cfg.backgroundPosition.y}
onChange={(pos) => upd("backgroundPosition", pos)}
/>
</div>
</div>
)}
</div>
{/* 3. 主标题 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("title")}>
<span>🔠 主标题</span>
<span>{sections.title ? "▾" : "▸"}</span>
</div>
{sections.title && (
<div className="xx-ce-section-body">
<TextStylePanel
config={cfg.title}
onChange={updTitle}
placeholder="输入主标题文字"
/>
</div>
)}
</div>
{/* 4. 副标题 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("subtitle")}>
<span>🔡 副标题</span>
<span>{sections.subtitle ? "▾" : "▸"}</span>
</div>
{sections.subtitle && (
<div className="xx-ce-section-body">
<TextStylePanel
config={cfg.subtitle}
onChange={updSubtitle}
placeholder="输入副标题文字"
/>
</div>
)}
</div>
{/* 5. 人像 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("portrait")}>
<span>👤 人像设置</span>
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={cfg.portraitEnabled}
onChange={(v) => upd("portraitEnabled", v)}
/>
</div>
</div>
{sections.portrait && cfg.portraitEnabled && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">人像图片</label>
<div className="xx-ce-file-row">
<input
className="xx-ce-file-name"
readOnly
value={portraitImageUrl ? "已选择图片" : "未选择图片"}
/>
<button
className="xx-ce-file-btn"
type="button"
onClick={() => portraitFileRef.current?.click()}
>
上传
</button>
{portraitImageUrl && (
<button
className="xx-ce-file-btn"
type="button"
onClick={() => {
setPortraitImageUrl("")
upd("portraitImage", "")
}}
>
清除
</button>
)}
<input
ref={portraitFileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) =>
handleFile(e.target.files?.[0], setPortraitImageUrl, "portraitImage")
}
/>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">大小: {cfg.portraitSize}%</label>
<Slider
min={10}
max={100}
step={1}
value={cfg.portraitSize}
onChange={(v) => upd("portraitSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">位置(X/Y %)</label>
<PositionPair
x={cfg.portraitPosition.x}
y={cfg.portraitPosition.y}
onChange={(pos) => upd("portraitPosition", pos)}
/>
</div>
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span>人物描边</span>
<Switch
size="small"
checked={cfg.personStrokeEnabled}
onChange={(v) => upd("personStrokeEnabled", v)}
/>
</div>
{cfg.personStrokeEnabled && (
<>
<div className="xx-ce-row">
<div className="xx-ce-radio-group">
{(["solid", "dashed"] as StrokeStyle[]).map((st) => (
<button
key={st}
className={`xx-ce-radio-btn ${cfg.personStrokeStyle === st ? "active" : ""}`}
onClick={() => upd("personStrokeStyle", st)}
type="button"
>
{st === "solid" ? "实线" : "虚线"}
</button>
))}
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">描边颜色</label>
<ColorPicker
value={cfg.personStrokeColor}
onChange={(v) => upd("personStrokeColor", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">描边粗细: {cfg.personStrokeWidth}px</label>
<Slider
min={0}
max={20}
step={1}
value={cfg.personStrokeWidth}
onChange={(v) => upd("personStrokeWidth", v)}
/>
</div>
</>
)}
</div>
</div>
)}
</div>
{/* 6. 蒙版 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("mask")}>
<span>🌓 蒙版</span>
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={cfg.maskEnabled}
onChange={(v) => upd("maskEnabled", v)}
/>
</div>
</div>
{sections.mask && cfg.maskEnabled && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">蒙版图片(可选)</label>
<div className="xx-ce-file-row">
<input
className="xx-ce-file-name"
readOnly
value={cfg.maskImage ? "已选择文件" : "未选择文件"}
/>
<button
className="xx-ce-file-btn"
type="button"
onClick={() => fileRef.current?.click()}
>
选择
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) upd("maskImage", f.name)
}}
/>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">蒙版颜色</label>
<ColorPicker value={cfg.maskColor} onChange={(v) => upd("maskColor", v)} />
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">不透明度: {cfg.maskOpacity}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.maskOpacity}
onChange={(v) => upd("maskOpacity", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">大小: {cfg.maskSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.maskSize}
onChange={(v) => upd("maskSize", v)}
/>
</div>
</div>
)}
</div>
</div>
{/* 右侧画布预览 */}
<div className="xx-cover-editor-right">
<div className="xx-cover-editor-canvas">
{/* 人像占位 */}
<div className="xx-cover-editor-portrait">
{/* 四角拖拽手柄 */}
<span className="xx-cover-editor-handle" style={{ top: -4, left: -4 }} />
<span className="xx-cover-editor-handle" style={{ top: -4, right: -4 }} />
<span className="xx-cover-editor-handle" style={{ bottom: -4, left: -4 }} />
<span className="xx-cover-editor-handle" style={{ bottom: -4, right: -4 }} />
{/* ── Right Preview ── */}
<div className="xx-ce-right">
<div className="xx-ce-canvas-wrap">
<span className="xx-ce-anchor-dot" style={{ top: "10%", left: "-12px" }} />
<span className="xx-ce-anchor-dot" style={{ top: "50%", right: "-12px" }} />
<span className="xx-ce-anchor-dot" style={{ bottom: "10%", left: "-12px" }} />
<div className="xx-ce-canvas">
{/* base background layer */}
<div
className="xx-ce-canvas-base"
style={{
position: "absolute",
inset: 0,
background: bgImageUrl
? `url(${bgImageUrl}) center/cover no-repeat`
: cfg.backgroundColor
? cfg.backgroundColor
: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
filter: cfg.blurEnabled ? `blur(${cfg.blurAmount / 4}px)` : undefined,
zIndex: 0,
}}
/>
{/* Background decoration */}
{cfg.backgroundEnabled && !bgImageUrl && (
<div
className="xx-ce-el-bg"
style={{
width: `${cfg.backgroundSize}%`,
height: `${cfg.backgroundSize}%`,
left: `${cfg.backgroundPosition.x}%`,
top: `${cfg.backgroundPosition.y}%`,
transform: "translate(-50%, -50%)",
zIndex: 1,
}}
/>
)}
{/* Portrait */}
{cfg.portraitEnabled && (
<div
className="xx-ce-el-portrait"
style={{
width: `${cfg.portraitSize}%`,
aspectRatio: "3/4",
height: "auto",
left: `${cfg.portraitPosition.x}%`,
top: `${cfg.portraitPosition.y}%`,
transform: "translate(-50%, -50%)",
borderStyle: cfg.personStrokeEnabled ? cfg.personStrokeStyle : "none",
borderColor: cfg.personStrokeEnabled ? cfg.personStrokeColor : "transparent",
borderWidth: cfg.personStrokeEnabled
? `${Math.max(1, s(cfg.personStrokeWidth))}px`
: "0",
background: portraitImageUrl
? `url(${portraitImageUrl}) center/contain no-repeat`
: "linear-gradient(135deg, #a8d4f0, #7ab8e0)",
zIndex: 2,
boxSizing: "border-box",
}}
/>
)}
{/* Title */}
<div style={renderTextStyle(cfg.title)}>{renderTextLines(cfg.title)}</div>
{/* Subtitle */}
<div style={renderTextStyle(cfg.subtitle)}>{renderTextLines(cfg.subtitle)}</div>
{/* Mask overlay */}
{cfg.maskEnabled && (
<div
className="xx-ce-el-mask"
style={{
backgroundColor: cfg.maskColor,
opacity: cfg.maskOpacity / 100,
}}
/>
)}
</div>
{/* 文字占位 */}
<div className="xx-cover-editor-title-placeholder">主标题文字</div>
<div className="xx-cover-editor-subtitle-placeholder">副标题文字</div>
</div>
<div className="xx-ce-preview-tip">👁️ 实时预览(9:16 竖版)</div>
</div>
</div>
</Modal>
@@ -40,12 +40,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 +73,78 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && (
<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)}
>
<div
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
>
🖼️
</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>
<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 && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
<Button buttonType="ghost" buttonSize="sm">
导出
</Button>
</div>
</div>
</div>
))}
{!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) => {
const isSelected = selectedTemplateId === tpl.id
return (
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<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-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={() => onEditTemplate(tpl)}>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</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>
)
}
+450
View File
@@ -2739,6 +2739,7 @@
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
/* 卡片信息区 */
@@ -3335,3 +3336,452 @@
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 }),
@@ -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("该视频尚未生成完成")
@@ -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 闭包
@@ -58,7 +58,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 +83,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)
}
})
@@ -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 () => {
+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
}
+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,
}
+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:
+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 / {
@@ -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,
}
+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
+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"
+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()