Compare commits

...

11 Commits

Author SHA1 Message Date
xiaoxia a998ccd527 fix(nginx): production 补 index.html no-cache 精确块,防旧缓存导致动态 import 失败 (#2031)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m41s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m4s
CI/CD Pipeline / Validate - Style (push) Successful in 5m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 6m11s
CI/CD Pipeline / Validate - Security (push) Successful in 9m39s
CI/CD Pipeline / Unit Tests (push) Successful in 10m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-24 18:47:53 +08:00
xiaoxia dd16f8f783 fix(generation): #2028 awaiting_cover 预览 + from-assets 容错 (#2029)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 57s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m0s
CI/CD Pipeline / Validate - Style (push) Successful in 4m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
CI/CD Pipeline / Integration Tests (push) Successful in 6m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 9m22s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 10m54s
CI/CD Pipeline / Validate - Security (push) Successful in 11m32s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-24 18:02:30 +08:00
xiaoxia 1528d6b59c fix(generate): Step5 完成按钮补调 finalize 接口,视频才真正入成品库 (#2030)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Integration Tests (push) Successful in 5m6s
CI/CD Pipeline / Validate - Style (push) Successful in 5m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m43s
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m15s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 11m46s
CI/CD Pipeline / Validate - Security (push) Successful in 14m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(generate): Step5「完成」按钮补上 finalize 调用(PR #2030)

- 新增 apps/web/src/api/generation/finalize.ts 封装 POST /generation/tasks/{id}/finalize
- handleFinish 改为 confirm → finalize 两步:confirm 同步封面+标题,finalize 真正入库成品库
- 批量场景每个 task 并行 confirm + finalize
- tsc/eslint/prettier 均通过
2026-09-24 17:21:40 +08:00
xiaoxia 445375e1cb feat(ai-avatar): 对口型生成弹窗加实时计时器 + 完成总耗时显示 (#2027)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m18s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 39s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Validate - Style (push) Successful in 4m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Validate - Security (push) Successful in 9m4s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 9m24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 9m53s
CI/CD Pipeline / Unit Tests (push) Successful in 10m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m36s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-24 12:40:27 +08:00
xiaoxia 23fe5f9822 feat(generation): #2024 视频渲染后延迟到封面确认才入库(新增 AWAITING_COVER 状态 + finalize 接口) (#2026)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 56s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-24 12:34:43 +08:00
xiaoxia c32065207a feat(generate): 封面编辑器完整实现 — 7个面板全部控件 + 右侧画布实时预览 (#2025)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 54s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Validate - Style (push) Successful in 5m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m48s
CI/CD Pipeline / Validate - Security (push) Successful in 12m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m9s
CI/CD Pipeline / Unit Tests (push) Successful in 13m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
feat: 封面编辑器完整实现
2026-09-24 11:28:34 +08:00
xiaoxia c9a8691b77 refactor(generate): Step4删除生成配置卡片;Step5默认封面模板+完成按钮保存入库 (#2024)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m42s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Style (push) Successful in 5m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m45s
CI/CD Pipeline / Integration Tests (push) Successful in 6m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 11m32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 12m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m0s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 16m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
refactor(generate): Step4删除生成配置卡片;Step5默认封面模板+完成按钮保存入库 (#2024)
2026-09-24 11:11:30 +08:00
xiaoxia 4a695c6eb6 fix(title): 标题/字幕字号按 video_width/720 等比缩放,修复成片标题比预览过小 (#2023)
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / 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 54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m2s
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 / CI Gate (pull_request) Successful in 13s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 48s
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 2m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Security (push) Successful in 13m27s
CI/CD Pipeline / Unit Tests (push) Successful in 14m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-23 23:17:12 +08:00
xiaoxia 04c28dc705 refactor(generate): 删除前端视频预览 + AI 标题生成,Step3 全宽展示标题模板卡片 (#2022)
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m18s
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 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m47s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI/CD Pipeline / Validate - Style (push) Successful in 5m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 7m30s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 12m30s
CI/CD Pipeline / Validate - Security (push) Successful in 14m42s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
refactor(generate): 删除前端视频预览 + AI 标题生成,Step3 全宽展示标题模板卡片

任务1 删除前端视频预览:
- 删除 FrontendPreviewPlayer/CanvasPreviewGrid/PreviewControls/PreviewProgressBar 组件
- 删除 usePreviewAssets/usePreviewAudio/useVariantVoicePreview/useSegmentScheduler hooks
- GeneratePage 移除预览相关 state/effect/callback 和左侧 xx-generate-preview-col 渲染块,布局统一 full-width

任务2 删除 AI 智能生成标题:
- 删除 AiTitleGenerator/AiTitleCard 组件和 useAiTitleGenerator hook
- 删除 AI_TITLE_TEMPLATES 常量和 AiTitleResult 类型
- Step4TitleSettings 单视频直接显示标题输入框,批量直接显示 N 个输入框,移除所有 AI 生成按钮/开关

任务3 Step3 标题页全宽展示模板卡片:
- 清理 generate.css 双列/侧边栏约束,标题输入 maxWidth:640px
- ttv3-grid 既有响应式(minmax(180px,1fr))自动适配 4-5 列

净删 3208 行代码,AI 数字人页面零改动。tsc/eslint 均零错误。
2026-09-23 22:17:21 +08:00
xiaoxia 493e85ce0e fix(web): 同步 package-lock.json ——修复 CI Staging Web 镜像构建失败 (npm ci EUSAGE) (#2021)
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 / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m41s
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 / 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 2m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m8s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 50s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
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 4m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m56s
CI/CD Pipeline / Validate - Style (push) Successful in 6m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m5s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m49s
CI/CD Pipeline / Integration Tests (push) Successful in 10m36s
CI/CD Pipeline / Unit Tests (push) Successful in 12m55s
CI/CD Pipeline / Validate - Security (push) Successful in 17m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(web): 同步 package-lock.json——classnames/dayjs 显式依赖导致 npm ci EUSAGE

PR#2020 在 apps/web/package.json 中新增 classnames ^2.5.1 和 dayjs ^1.11.23 显式依赖时,只用 pnpm add 更新了 pnpm-lock.yaml,未同步 CI(web.Dockerfile)实际使用的 package-lock.json。lockfile 中 dayjs 仍停留在 1.11.21(antd 传递依赖),npm ci 严格校验版本不匹配,导致 CI Run #54590 Build Staging Web Image 连续三次失败。

修复:重新 npm install 同步 package-lock.json,dayjs 升至 1.11.23,classnames 提升为顶级依赖。本地 npm ci / tsc / vite build / eslint 均通过。
2026-09-23 17:16:19 +08:00
xiaoxia 445267a3b2 fix(title): 标题模板v3全面审计修复——选模板预览不变/新建保存变白 (#2020)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m25s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m35s
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 API Image (push) Successful in 1m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m39s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
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 / Build Staging Worker Image (push) Successful in 41s
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 4m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m51s
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 / Frontend Unit Tests (push) Failing after 5m32s
CI/CD Pipeline / Validate - Style (push) Successful in 5m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
AI Code Review / AI Code Review (pull_request) Successful in 7m5s
CI/CD Pipeline / Validate - Security (push) Successful in 17m30s
CI/CD Pipeline / Unit Tests (push) Successful in 17m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-23 15:53:25 +08:00
67 changed files with 4321 additions and 3928 deletions
+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
+5 -3
View File
@@ -12,6 +12,8 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -3005,9 +3007,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"version": "1.11.23",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
+2
View File
@@ -23,6 +23,8 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+137 -115
View File
@@ -18,7 +18,16 @@ importers:
version: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
axios:
specifier: ^1.7.2
version: 1.18.1
version: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
classnames:
specifier: ^2.5.1
version: 2.5.1
dayjs:
specifier: ^1.11.23
version: 1.11.23
mp4box:
specifier: ^2.4.1
version: 2.4.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -43,7 +52,7 @@ importers:
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
"@testing-library/user-event":
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
version: 14.6.7(@testing-library/dom@10.4.1)
"@types/node":
specifier: ^20.14.9
version: 20.19.43
@@ -55,34 +64,34 @@ importers:
version: 18.3.7(@types/react@18.3.31)
"@typescript-eslint/eslint-plugin":
specifier: ^7.13.1
version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)
version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/parser":
specifier: ^7.13.1
version: 7.13.1(eslint@8.57.0)(typescript@5.5.3)
version: 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@vitejs/plugin-react":
specifier: ^4.3.1
version: 4.3.1(vite@5.3.1(@types/node@20.19.43))
version: 4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))
"@vitest/coverage-v8":
specifier: ^1.6.1
version: 1.6.1(vitest@1.6.0)
version: 1.6.1(supports-color@7.2.0)(vitest@1.6.0)
"@vitest/ui":
specifier: ^1.6.0
version: 1.6.0(vitest@1.6.0)
eslint:
specifier: ^8.57.0
version: 8.57.0
version: 8.57.0(supports-color@7.2.0)
eslint-config-prettier:
specifier: ^9.1.2
version: 9.1.2(eslint@8.57.0)
version: 9.1.2(eslint@8.57.0(supports-color@7.2.0))
eslint-plugin-react-hooks:
specifier: ^4.6.2
version: 4.6.2(eslint@8.57.0)
version: 4.6.2(eslint@8.57.0(supports-color@7.2.0))
eslint-plugin-react-refresh:
specifier: ^0.4.7
version: 0.4.26(eslint@8.57.0)
version: 0.4.26(eslint@8.57.0(supports-color@7.2.0))
jsdom:
specifier: ^24.1.0
version: 24.1.0
version: 24.1.0(supports-color@7.2.0)
prettier:
specifier: ^3.9.5
version: 3.9.5
@@ -94,7 +103,7 @@ importers:
version: 5.3.1(@types/node@20.19.43)
vitest:
specifier: ^1.6.0
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
packages:
"@adobe/css-tools@4.5.0":
@@ -1127,10 +1136,10 @@ packages:
"@types/react-dom":
optional: true
"@testing-library/user-event@14.6.1":
"@testing-library/user-event@14.6.7":
resolution:
{
integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==,
integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==,
}
engines: { node: ">=12", npm: ">=6" }
peerDependencies:
@@ -1672,10 +1681,10 @@ packages:
}
engines: { node: ">=18" }
dayjs@1.11.21:
dayjs@1.11.23:
resolution:
{
integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==,
integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==,
}
debug@4.4.3:
@@ -2546,6 +2555,13 @@ packages:
integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==,
}
mp4box@2.4.1:
resolution:
{
integrity: sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==,
}
engines: { node: ">=20.8.1" }
mrmime@2.0.1:
resolution:
{
@@ -3861,20 +3877,20 @@ snapshots:
"@babel/compat-data@7.29.7": {}
"@babel/core@7.29.7":
"@babel/core@7.29.7(supports-color@7.2.0)":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
"@babel/helper-compilation-targets": 7.29.7
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
"@babel/helpers": 7.29.7
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/types": 7.29.7
"@jridgewell/remapping": 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3899,19 +3915,19 @@ snapshots:
"@babel/helper-globals@7.29.7": {}
"@babel/helper-module-imports@7.29.7":
"@babel/helper-module-imports@7.29.7(supports-color@7.2.0)":
dependencies:
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)":
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)":
dependencies:
"@babel/core": 7.29.7
"@babel/helper-module-imports": 7.29.7
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/helper-module-imports": 7.29.7(supports-color@7.2.0)
"@babel/helper-validator-identifier": 7.29.7
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3932,14 +3948,14 @@ snapshots:
dependencies:
"@babel/types": 7.29.7
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/helper-plugin-utils": 7.29.7
"@babel/runtime@7.29.7": {}
@@ -3950,7 +3966,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/types": 7.29.7
"@babel/traverse@7.29.7":
"@babel/traverse@7.29.7(supports-color@7.2.0)":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
@@ -3958,7 +3974,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/types": 7.29.7
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -4044,17 +4060,17 @@ snapshots:
"@esbuild/win32-x64@0.21.3":
optional: true
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)":
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0(supports-color@7.2.0))":
dependencies:
eslint: 8.57.0
eslint: 8.57.0(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
"@eslint-community/regexpp@4.12.2": {}
"@eslint/eslintrc@2.1.4":
"@eslint/eslintrc@2.1.4(supports-color@7.2.0)":
dependencies:
ajv: 6.15.0
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
espree: 9.6.1
globals: 13.19.0
ignore: 5.2.0
@@ -4067,10 +4083,10 @@ snapshots:
"@eslint/js@8.57.0": {}
"@humanwhocodes/config-array@0.11.14":
"@humanwhocodes/config-array@0.11.14(supports-color@7.2.0)":
dependencies:
"@humanwhocodes/object-schema": 2.0.3
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -4360,7 +4376,7 @@ snapshots:
"@types/react": 18.3.31
"@types/react-dom": 18.3.7(@types/react@18.3.31)
"@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)":
"@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)":
dependencies:
"@testing-library/dom": 10.4.1
@@ -4420,15 +4436,15 @@ snapshots:
dependencies:
"@types/yargs-parser": 21.0.3
"@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)":
"@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
dependencies:
"@eslint-community/regexpp": 4.12.2
"@typescript-eslint/parser": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/parser": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/visitor-keys": 7.13.1
eslint: 8.57.0
eslint: 8.57.0(supports-color@7.2.0)
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
@@ -4438,14 +4454,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
"@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
dependencies:
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/visitor-keys": 7.13.1
debug: 4.4.3
eslint: 8.57.0
debug: 4.4.3(supports-color@7.2.0)
eslint: 8.57.0(supports-color@7.2.0)
optionalDependencies:
typescript: 5.5.3
transitivePeerDependencies:
@@ -4456,12 +4472,12 @@ snapshots:
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/visitor-keys": 7.13.1
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
dependencies:
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
debug: 4.4.3
eslint: 8.57.0
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
debug: 4.4.3(supports-color@7.2.0)
eslint: 8.57.0(supports-color@7.2.0)
ts-api-utils: 1.3.0(typescript@5.5.3)
optionalDependencies:
typescript: 5.5.3
@@ -4470,11 +4486,11 @@ snapshots:
"@typescript-eslint/types@7.13.1": {}
"@typescript-eslint/typescript-estree@7.13.1(typescript@5.5.3)":
"@typescript-eslint/typescript-estree@7.13.1(supports-color@7.2.0)(typescript@5.5.3)":
dependencies:
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/visitor-keys": 7.13.1
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.9
@@ -4485,13 +4501,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@typescript-eslint/utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
"@typescript-eslint/utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
dependencies:
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
eslint: 8.57.0
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
eslint: 8.57.0(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- typescript
@@ -4503,25 +4519,25 @@ snapshots:
"@ungap/structured-clone@1.3.3": {}
"@vitejs/plugin-react@4.3.1(vite@5.3.1(@types/node@20.19.43))":
"@vitejs/plugin-react@4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))":
dependencies:
"@babel/core": 7.29.7
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7)
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
"@types/babel__core": 7.20.5
react-refresh: 0.14.2
vite: 5.3.1(@types/node@20.19.43)
transitivePeerDependencies:
- supports-color
"@vitest/coverage-v8@1.6.1(vitest@1.6.0)":
"@vitest/coverage-v8@1.6.1(supports-color@7.2.0)(vitest@1.6.0)":
dependencies:
"@ampproject/remapping": 2.3.0
"@bcoe/v8-coverage": 0.2.3
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0)
istanbul-reports: 3.2.0
magic-string: 0.30.5
magicast: 0.3.3
@@ -4529,7 +4545,7 @@ snapshots:
std-env: 3.5.0
strip-literal: 2.1.1
test-exclude: 6.0.0
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -4564,7 +4580,7 @@ snapshots:
pathe: 1.1.2
picocolors: 1.1.1
sirv: 2.0.4
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
"@vitest/utils@1.6.0":
dependencies:
@@ -4583,21 +4599,21 @@ snapshots:
acorn@8.17.0: {}
agent-base@6.0.0:
agent-base@6.0.0(supports-color@7.2.0):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
agent-base@7.0.2:
agent-base@7.0.2(supports-color@7.2.0):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
agent-base@7.1.0:
agent-base@7.1.0(supports-color@7.2.0):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -4632,7 +4648,7 @@ snapshots:
"@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
classnames: 2.5.1
copy-to-clipboard: 3.3.3
dayjs: 1.11.21
dayjs: 1.11.23
rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4648,7 +4664,7 @@ snapshots:
rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-picker: 4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4688,11 +4704,11 @@ snapshots:
asynckit@0.4.0: {}
axios@1.18.1:
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
follow-redirects: 1.16.0
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
form-data: 4.0.6
https-proxy-agent: 5.0.1
https-proxy-agent: 5.0.1(supports-color@7.2.0)
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -4796,11 +4812,13 @@ snapshots:
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
dayjs@1.11.21: {}
dayjs@1.11.23: {}
debug@4.4.3:
debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
decimal.js@10.6.0: {}
@@ -4883,17 +4901,17 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@9.1.2(eslint@8.57.0):
eslint-config-prettier@9.1.2(eslint@8.57.0(supports-color@7.2.0)):
dependencies:
eslint: 8.57.0
eslint: 8.57.0(supports-color@7.2.0)
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0):
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0(supports-color@7.2.0)):
dependencies:
eslint: 8.57.0
eslint: 8.57.0(supports-color@7.2.0)
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0):
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0(supports-color@7.2.0)):
dependencies:
eslint: 8.57.0
eslint: 8.57.0(supports-color@7.2.0)
eslint-scope@7.2.2:
dependencies:
@@ -4902,20 +4920,20 @@ snapshots:
eslint-visitor-keys@3.4.3: {}
eslint@8.57.0:
eslint@8.57.0(supports-color@7.2.0):
dependencies:
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
"@eslint-community/regexpp": 4.12.2
"@eslint/eslintrc": 2.1.4
"@eslint/eslintrc": 2.1.4(supports-color@7.2.0)
"@eslint/js": 8.57.0
"@humanwhocodes/config-array": 0.11.14
"@humanwhocodes/config-array": 0.11.14(supports-color@7.2.0)
"@humanwhocodes/module-importer": 1.0.1
"@nodelib/fs.walk": 1.2.8
"@ungap/structured-clone": 1.3.3
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -5019,7 +5037,9 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0: {}
follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)):
optionalDependencies:
debug: 4.4.3(supports-color@7.2.0)
form-data@4.0.6:
dependencies:
@@ -5115,24 +5135,24 @@ snapshots:
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
http-proxy-agent@7.0.2(supports-color@7.2.0):
dependencies:
agent-base: 7.1.0
debug: 4.4.3
agent-base: 7.1.0(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1:
https-proxy-agent@5.0.1(supports-color@7.2.0):
dependencies:
agent-base: 6.0.0
debug: 4.4.3
agent-base: 6.0.0(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.4:
https-proxy-agent@7.0.4(supports-color@7.2.0):
dependencies:
agent-base: 7.0.2
debug: 4.4.3
agent-base: 7.0.2(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -5186,10 +5206,10 @@ snapshots:
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
istanbul-lib-source-maps@5.0.6(supports-color@7.2.0):
dependencies:
"@jridgewell/trace-mapping": 0.3.31
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -5209,15 +5229,15 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@24.1.0:
jsdom@24.1.0(supports-color@7.2.0):
dependencies:
cssstyle: 4.0.1
data-urls: 5.0.0
decimal.js: 10.6.0
form-data: 4.0.6
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4
http-proxy-agent: 7.0.2(supports-color@7.2.0)
https-proxy-agent: 7.0.4(supports-color@7.2.0)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.24
parse5: 7.1.2
@@ -5331,6 +5351,8 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
mp4box@2.4.1: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -5611,7 +5633,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
rc-picker@4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
rc-picker@4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
"@babel/runtime": 7.29.7
"@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -5622,7 +5644,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
dayjs: 1.11.21
dayjs: 1.11.23
rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -6008,10 +6030,10 @@ snapshots:
dependencies:
react: 18.3.1
vite-node@1.6.0(@types/node@20.19.43):
vite-node@1.6.0(@types/node@20.19.43)(supports-color@7.2.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.3.1(@types/node@20.19.43)
@@ -6034,7 +6056,7 @@ snapshots:
"@types/node": 20.19.43
fsevents: 2.3.3
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0):
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
"@vitest/expect": 1.6.0
"@vitest/runner": 1.6.0
@@ -6043,7 +6065,7 @@ snapshots:
"@vitest/utils": 1.6.0
acorn-walk: 8.3.5
chai: 4.3.10
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
execa: 8.0.1
local-pkg: 0.5.0
magic-string: 0.30.5
@@ -6054,12 +6076,12 @@ snapshots:
tinybench: 2.5.1
tinypool: 0.8.3
vite: 5.3.1(@types/node@20.19.43)
vite-node: 1.6.0(@types/node@20.19.43)
vite-node: 1.6.0(@types/node@20.19.43)(supports-color@7.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
"@types/node": 20.19.43
"@vitest/ui": 1.6.0(vitest@1.6.0)
jsdom: 24.1.0
jsdom: 24.1.0(supports-color@7.2.0)
transitivePeerDependencies:
- less
- lightningcss
+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
}
@@ -16,21 +16,21 @@ import { FONT_OPTIONS } from "./constants"
import type { TitleSettings } from "../../pages/generate/types"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
import { useTitleTemplates } from "./useTitleTemplates"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleConfig } from "./types"
interface Props {
open: boolean
template: TitleTemplate
onClose: () => void
onSaved: (template: TitleTemplate) => void
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
}
/** 编辑器预览用的背景图(复用卡片池第一张) */
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved }) => {
const { updateTemplate, createTemplate } = useTitleTemplates()
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleSettings>(() => ({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
@@ -63,19 +63,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
setSaving(true)
try {
const snake = camelToTitleStyleConfig(settings)
if (template.isBuiltin) {
const t = createTemplate({ name, emoji: formEmoji, style: snake })
onSaved(t)
} else {
updateTemplate(template.id, { name, emoji: formEmoji, style: snake })
onSaved({
...template,
name,
emoji: formEmoji,
style: snake,
updatedAt: new Date().toISOString(),
})
}
onSave({ name, emoji: formEmoji, style: snake })
} finally {
setSaving(false)
}
@@ -89,7 +77,13 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
return (
<Modal
title={template.isBuiltin ? `基于「${template.name}」创建模板` : `编辑模板:${template.name}`}
title={
!template.id
? "新建模板"
: template.isBuiltin
? `基于「${template.name}」创建模板`
: `编辑模板:${template.name}`
}
open={open}
onCancel={onClose}
width={860}
@@ -127,6 +127,7 @@ export interface TitlePreset {
}
const BASE: Partial<TitleStyleConfig> = {
position: "bottom",
line_overrides: [],
cover_title_config: null,
}
@@ -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" && (
+84 -211
View File
@@ -8,15 +8,12 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
import { useQuery } from "@tanstack/react-query"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import VoiceSelectModal from "./components/VoiceSelectModal"
import ScriptSelectModal from "./components/ScriptSelectModal"
import TtsVoiceModal from "./components/TtsVoiceModal"
import GenerateHeader from "./components/GenerateHeader"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
@@ -24,13 +21,10 @@ import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { finalizeGeneration } from "@/api/generation/finalize"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import { usePointsStore } from "@/store/pointsStore"
import { hasEnoughPoints } from "./hooks/pointsCost"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
@@ -101,8 +95,6 @@ const GeneratePage: React.FC = () => {
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
serverClips,
setServerClips,
previewCount,
setPreviewCount,
previewTitles,
@@ -148,6 +140,9 @@ const GeneratePage: React.FC = () => {
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
/* ── #1970 流程重构:分支弹窗 ── */
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
const [scriptModalOpen, setScriptModalOpen] = useState(false)
@@ -159,62 +154,6 @@ const GeneratePage: React.FC = () => {
onTitleSettingsChange: setTitleSettings,
})
/* ── 配音素材库(TTS 试听)── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
const ttsAbortRef = useRef<AbortController | null>(null)
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
useEffect(() => {
if (isBatch) return
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
if (voiceAsset?.file_url) {
setPreviewVoiceAudioUrl(voiceAsset.file_url)
return
}
const ttsTitle = titleSettings.title
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !ttsTitle) {
setPreviewVoiceAudioUrl(null)
return
}
ttsAbortRef.current?.abort()
const controller = new AbortController()
ttsAbortRef.current = controller
let cancelled = false
previewTts({ text: ttsTitle, voice_id: voiceId })
.then((res) => {
if (!cancelled && res.audio_url) {
setPreviewVoiceAudioUrl(res.audio_url)
}
})
.catch((err) => {
if (!cancelled) {
console.warn("[预览配音生成失败]", err)
setPreviewVoiceAudioUrl(null)
}
})
return () => {
cancelled = true
controller.abort()
}
}, [
selectedVoice,
selectedClonedVoice,
titleSettings.title,
variant0Title,
isBatch,
voiceMaterials,
])
/* ── 克隆声音 ── */
const { addClone } = useCloneProgress()
@@ -239,30 +178,11 @@ const GeneratePage: React.FC = () => {
[bgm],
)
/* ── 加载素材详情 ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const {
assets: previewAssets,
ready: previewAssetsReady,
ensureAssets,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 预览就绪 ── */
// #1899: 不再依赖 currentTemplate,素材加载完即可预览
const previewReady = previewAssetsReady
/* ── 批量变体真实片段(#1744) ── */
const batchVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const {
clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds,
voiceDurationsByVariant: variantVoiceDurations,
loading: variantClipsLoading,
error: variantClipsError,
retry: retryVariantClips,
} = useBatchVariantPlans({
enabled: isBatch && currentStep === 3 && previewAssetsReady,
const { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({
enabled: isBatch && currentStep === 3,
count: previewCount,
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
@@ -272,39 +192,6 @@ const GeneratePage: React.FC = () => {
voiceModePerVideo,
})
/* ── 批量变体配音预览 URL ── */
const variantVoiceAudioUrls = useVariantVoicePreview({
enabled: isBatch,
count: previewCount,
perVideo: voiceModePerVideo,
sharedVoiceId: selectedVoice || "",
clonedVoiceId: selectedClonedVoice || "",
variantVoiceIds: voiceLibraryIds || [],
titles: previewTitles || [],
})
/* ── 变体 clips 引用素材补拉 ── */
const clipAssetIds = useMemo(() => {
if (!isBatch || !variantClips?.length) return []
const ids = new Set<string>()
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
return Array.from(ids)
}, [isBatch, variantClips])
useEffect(() => {
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
}, [clipAssetIds, ensureAssets])
/* ── 勾选变体 ── */
const toggleVariantSelect = useCallback(
(index: number) => {
setSelectedVariantIds((prev) => {
const list = prev || []
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
})
},
[setSelectedVariantIds],
)
/* ── 视频生成核心逻辑 ── */
const {
generating,
@@ -484,10 +371,6 @@ const GeneratePage: React.FC = () => {
return
}
}
if (!previewReady) {
message.warning("预览素材正在加载,请稍候")
return
}
const ok = await handleGenerate()
if (ok) {
setCurrentStep(4)
@@ -498,7 +381,6 @@ const GeneratePage: React.FC = () => {
previewTitles,
titleSettings.aiAutoSelect,
titleSettings.title,
previewReady,
handleGenerate,
setCurrentStep,
balance,
@@ -526,14 +408,84 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 最终成片 ── */
/* ── 最终成片(单视频) ── */
const finalVideo = generatedVideos[0]
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
const handleFinish = useCallback(async () => {
if (finishing) return
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
if (isBatch) {
if (generatedVideos.length === 0) {
message.warning("请等待视频生成完成")
return
}
} else {
if (!finalVideo) {
message.warning("请等待视频生成完成")
return
}
}
setFinishing(true)
const hide = message.loading("正在保存到视频库...", 0)
try {
const taskIds =
batchTasks && batchTasks.length > 0
? batchTasks.map((t) => t.taskId).filter(Boolean)
: finalVideo?.generation_task_id
? [finalVideo.generation_task_id]
: []
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
if (isBatch && previewCovers.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return finalizeGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
})
}),
)
} else if (finalVideo?.generation_task_id) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await finalizeGeneration(finalVideo.generation_task_id, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
}
hide()
message.success("已保存到视频库")
navigate("/app/products")
} catch (err) {
hide()
console.error("[保存失败]", err)
const detail =
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
?.detail ||
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
?.message ||
"保存失败,请稍后在任务历史查看"
message.error(detail)
} finally {
setFinishing(false)
}
}, [
finishing,
isBatch,
finalVideo,
generatedVideos,
batchTasks,
previewCovers,
previewTitles,
titleSettings.title,
coverSettings,
navigate,
])
/* ── 布局 class ── */
const layoutClassName = useMemo(() => {
if (currentStep === 3) return "xx-generate-layout step4-layout"
return "xx-generate-layout full-width"
}, [currentStep])
const layoutClassName = "xx-generate-layout full-width"
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
@@ -561,78 +513,7 @@ const GeneratePage: React.FC = () => {
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤3:左侧预览大区域 ════ */}
{currentStep === 3 && (
<div className="xx-generate-preview-col">
{!isBatch ? (
<FrontendPreviewPlayer
assets={previewAssets}
videoRatio={videoRatio as "9:16" | "16:9"}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
strokeWidth: titleSettings.strokeWidth,
strokeColor: titleSettings.strokeColor,
shadow: titleSettings.shadow,
shadowOffsetX: titleSettings.shadowOffsetX,
shadowOffsetY: titleSettings.shadowOffsetY,
shadowBlur: titleSettings.shadowBlur,
shadowColor: titleSettings.shadowColor,
bgEnabled: titleSettings.bgEnabled,
bgColor: titleSettings.bgColor,
bgPadding: titleSettings.bgPadding,
bgRadius: titleSettings.bgRadius,
lineHeight: titleSettings.lineHeight,
maxCharsPerLine: titleSettings.maxCharsPerLine,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
) : (
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} </h3>
<span
style={{
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
</span>
</div>
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
videoRatio={videoRatio as "9:16" | "16:9"}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
voiceDurations={variantVoiceDurations}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
onRetryClips={retryVariantClips}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
/>
</div>
)}
</div>
)}
{/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
currentStep={currentStep}
@@ -676,11 +557,7 @@ const GeneratePage: React.FC = () => {
onPreviewCountChange={setPreviewCount}
videoRatio={videoRatio as "9:16" | "16:9"}
onVideoRatioChange={(r) => setVideoRatio(r)}
selectedScript={selectedScript}
ttsVoiceId={ttsVoiceId}
ttsVoiceSource={ttsVoiceSource}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
generating={generating}
generated={generated}
generateError={generateError}
@@ -762,12 +639,6 @@ const GeneratePage: React.FC = () => {
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁
</button>
</div>
</div>
</div>
@@ -787,6 +658,8 @@ const GeneratePage: React.FC = () => {
pointsInsufficient={insufficientPoints}
insufficientReason={pointsEstimate.reason}
onRecharge={() => navigate("/app/points/recharge")}
onFinish={handleFinish}
finishing={finishing}
/>
</div>
</div>
@@ -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×19209: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" && (
@@ -1,168 +0,0 @@
/**
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
*
* N 个 FrontendPreviewPlayer 网格排列:
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
* 加载中 → 9:16 占位防塌陷;
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
* - 勾选框决定提交时生成哪些变体;
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
*/
import React, { useState } from "react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
interface CanvasPreviewGridProps {
count: number
assets: AssetItem[]
videoRatio: string
titles: string[]
titleSettings: TitleSettings
/**
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
*/
voiceAudioUrls?: (string | null)[]
/**
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
* 长度=countundefined 项表示该变体未提供后端时长。
*/
voiceDurations?: (number | undefined)[]
/**
* 各变体的后端真实片段(#1744/#1750):长度=count。
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
*/
variantClips?: EditPlanClip[][]
/** 是否正在向后端申请变体计划 */
clipsLoading?: boolean
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
clipsError?: boolean
/** 用户点击错误态「重试」 */
onRetryClips?: () => void
/** 勾选的变体序号 */
selectedIds: number[]
onToggleSelect: (index: number) => void
/** 生成中禁止勾选 */
selectable?: boolean
}
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
count,
assets,
videoRatio,
titles,
titleSettings,
voiceAudioUrls,
voiceDurations,
variantClips,
clipsLoading = false,
clipsError = false,
onRetryClips,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750 ──
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10clamp
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
<div className="xx-canvas-grid">
{Array.from({ length: count }, (_, i) => {
const checked = selectedIds.includes(i)
return (
<div
key={i}
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
data-variant={i}
>
<div className="xx-canvas-grid-card-bar">
<label className="xx-canvas-grid-check">
<input
type="checkbox"
checked={checked}
disabled={!selectable}
onChange={() => onToggleSelect(i)}
/>
<span> {i + 1}</span>
</label>
</div>
{clipsError ? (
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
<div className="xx-variant-clips-status" role="alert">
<span className="xx-variant-clips-error-text"></span>
{i === 0 && onRetryClips ? (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={onRetryClips}
style={{ marginTop: 12 }}
>
</Button>
) : null}
</div>
) : clipsLoading || !variantClips?.[i]?.length ? (
// ── 加载态:9:16 占位防塌陷 ──
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
<LoadingOutlined />
<span style={{ marginTop: 8 }}></span>
</div>
) : (
<FrontendPreviewPlayer
assets={assets}
videoRatio={videoRatio}
ready={assets.length > 0}
playToken={i}
serverClips={variantClips[i]}
variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
voiceDurationHint={voiceDurations?.[i]}
activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken}
compact
titleSettings={{
title: titles[i] || "",
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
strokeWidth: titleSettings.strokeWidth,
strokeColor: titleSettings.strokeColor,
shadow: titleSettings.shadow,
shadowOffsetX: titleSettings.shadowOffsetX,
shadowOffsetY: titleSettings.shadowOffsetY,
shadowBlur: titleSettings.shadowBlur,
shadowColor: titleSettings.shadowColor,
bgEnabled: titleSettings.bgEnabled,
bgColor: titleSettings.bgColor,
bgPadding: titleSettings.bgPadding,
bgRadius: titleSettings.bgRadius,
lineHeight: titleSettings.lineHeight,
maxCharsPerLine: titleSettings.maxCharsPerLine,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
/>
)}
</div>
)
})}
</div>
)
}
export default CanvasPreviewGrid
@@ -1,633 +0,0 @@
/**
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
*
* 架构:
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
*
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { usePreviewAudio } from "../hooks/usePreviewAudio"
import { PreviewControls } from "./PreviewControls"
import { getFontFamily } from "@/components/title/constants"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
videoRatio: string
ready: boolean
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
serverClips?: EditPlanClip[]
voiceAudioUrl?: string
titleSettings?: {
title: string
size: number
font: string
color: string
position: "top" | "center" | "bottom" | "custom"
bold?: boolean
italic?: boolean
stroke?: boolean
shadow?: boolean
strokeWidth?: number
strokeColor?: string
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineHeight?: number
maxCharsPerLine?: number
posX?: number | null
posY?: number | null
}
onTitlePositionChange?: (posX: number, posY: number) => void
/**
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
* 持有播放权的实例播放,其余自动暂停。
*/
playToken?: number
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
variantTitle?: string
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
compact?: boolean
/**
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
*/
activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void
/**
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
* 未提供则在 audio loadedmetadata 后自测兜底。
*/
voiceDurationHint?: number
}
/**
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
*
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
*/
function buildPlaybackSegments(
assets: AssetItem[],
serverClips?: EditPlanClip[],
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
speedFactor = 1,
): PlaybackSegment[] {
if (!assets.length || !serverClips || serverClips.length === 0) return []
const assetMap = new Map(assets.map((a) => [a.id, a]))
const segments: PlaybackSegment[] = []
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 0
const startTime = clip.start_time || 0
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
const rawClipDuration = clip.duration || 0
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
const endTime =
assetDuration > 0
? Math.min(startTime + clipDuration, assetDuration)
: startTime + clipDuration
const videoUrl = asset.file_url || asset.storage_key
segments.push({
assetId: asset.id,
videoUrl,
startTime,
endTime,
order: clip.order,
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
})
}
return segments.sort((a, b) => a.order - b.order)
}
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
videoRatio,
ready,
serverClips,
voiceAudioUrl,
voiceDurationHint,
titleSettings,
onTitlePositionChange,
playToken,
variantTitle,
compact = false,
activePlayToken = null,
onPlayTokenChange,
}) => {
// #1754→P0:配音时长作为音画时长锚点。
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
)
// #1756clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
const rawClipsDuration = useMemo(() => {
if (!serverClips?.length) return 0
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
}, [serverClips])
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
const speedFactor = useMemo(() => {
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
const ratio = rawClipsDuration / voiceDuration
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
}, [voiceDuration, rawClipsDuration])
const segments = useMemo(
() => buildPlaybackSegments(assets, serverClips, speedFactor),
[assets, serverClips, speedFactor],
)
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
const effectiveTitle = variantTitle ?? titleSettings?.title
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
const TITLE_MARGIN_TOP = 180
const TITLE_MARGIN_BOTTOM = 100
const TITLE_MARGIN_SIDE = 40
const playRes = (() => {
switch (videoRatio) {
case "16:9":
return { width: 1920, height: 1080 }
case "1:1":
return { width: 1080, height: 1080 }
case "9:16":
default:
return { width: 1080, height: 1920 }
}
})()
const customTitleXPct =
titleSettings?.posX != null && playRes.width > 0
? (titleSettings.posX / playRes.width) * 100
: null
const customTitleYPct =
titleSettings?.posY != null && playRes.height > 0
? (titleSettings.posY / playRes.height) * 100
: null
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
;(e.target as Element).setPointerCapture(e.pointerId)
draggingTitleRef.current = true
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
},
[onTitlePositionChange],
)
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
if (titleDragRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const xpct = (relX / rect.width) * 100
const ypct = (relY / rect.height) * 100
titleDragRef.current.style.left = `${xpct}%`
titleDragRef.current.style.top = `${ypct}%`
}
}, [])
const handleTitlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
},
[onTitlePositionChange, playRes.width, playRes.height],
)
const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => {
const el = playerContainerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height
if (h > 0) setContainerHeight(h)
}
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
// 标题字号按容器高度与 PlayResY 的比例缩放
const titleFontSizePx =
containerHeight > 0
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
: (titleSettings?.size ?? 36)
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
// 标题缩放到容器:字号 px = (settings.size / playRes.height) * containerHeight
// 描边/阴影:读 titleSettings 真实值,按容器比例缩放
const titleStrokeWidth = Math.max(
1,
((titleSettings?.strokeWidth ?? 4) / playRes.height) * containerHeight,
)
const titleStrokeColor = titleSettings?.strokeColor ?? "#000000"
const titleShadowBlur = ((titleSettings?.shadowBlur ?? 4) / playRes.height) * containerHeight
const titleShadowOffsetX =
((titleSettings?.shadowOffsetX ?? 2) / playRes.height) * containerHeight
const titleShadowOffsetY =
((titleSettings?.shadowOffsetY ?? 2) / playRes.height) * containerHeight
const titleShadowColor = titleSettings?.shadowColor ?? "rgba(0,0,0,0.8)"
const titleShadowStr = `${titleShadowOffsetX}px ${titleShadowOffsetY}px ${titleShadowBlur}px ${titleShadowColor}`
// 标题背景色块
const titleBgEnabled = !!titleSettings?.bgEnabled
const titleBgColor = titleSettings?.bgColor ?? "rgba(0,0,0,0.5)"
const titleBgPadding = ((titleSettings?.bgPadding ?? 12) / playRes.height) * containerHeight
const titleBgRadius = ((titleSettings?.bgRadius ?? 8) / playRes.height) * containerHeight
// ── Video 播放器(默认路径,浏览器原生硬件解码) ──
const {
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
canPlay,
togglePlayPause,
seekTo,
pause,
videoRefs,
} = useSegmentScheduler(segments)
// P0 fix:以配音时长为音画同步锚点。
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
const effectiveTotalDuration =
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl
// 音频 ended:兜底触发暂停与释放播放权
const handleAudioEnded = useCallback(() => {
if (!isPlaying) return
pause()
if (playToken != null) onPlayTokenChange?.(null)
}, [isPlaying, pause, playToken, onPlayTokenChange])
const {
seekTo: audioSeekTo,
ensurePlayingAt: audioEnsurePlayingAt,
pause: audioPause,
} = usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange: setVoiceDuration,
onEnded: handleAudioEnded,
})
// 片段切换时同步音频时间(video fallback
useEffect(() => {
if (!isPlaying) return
audioSeekTo(currentTime)
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSegmentIndex, isPlaying])
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
const tailStartRef = useRef<number>(0)
const tailBaseRef = useRef<number>(0)
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
useEffect(() => {
const needTail =
!!voiceAudioUrl &&
voiceDuration > 0 &&
!isPlaying &&
typeof currentTime === "number" &&
currentTime >= totalDuration - 0.1 &&
currentTime < voiceDuration - 0.1
if (needTail && tailCurrentTime === null) {
tailBaseRef.current = currentTime
tailStartRef.current = performance.now()
setTailCurrentTime(currentTime)
tailAudioRef.current.ensurePlayingAt(currentTime)
return
}
if (!needTail && tailCurrentTime !== null) {
setTailCurrentTime(null)
}
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
useEffect(() => {
if (tailCurrentTime === null) return
let raf = 0
const tick = () => {
const elapsed = (performance.now() - tailStartRef.current) / 1000
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
setTailCurrentTime(t)
tailAudioRef.current.ensurePlayingAt(t)
if (t >= (voiceDuration || 0) - 0.05) {
tailAudioRef.current.pause()
if (playToken != null) onPlayTokenChange?.(null)
setTailCurrentTime(null)
return
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
const handleSeekTo = useCallback(
(time: number) => {
setTailCurrentTime(null)
seekTo(time)
audioSeekTo(time)
},
[seekTo, audioSeekTo],
)
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
useEffect(() => {
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
if (isPlaying) {
pause()
}
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, playToken])
const handleTogglePlay = useCallback(() => {
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
togglePlayPause()
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
useEffect(() => {
if (!isPlaying) return
if (!voiceAudioUrl || voiceDuration <= 0) return
if (displayCurrentTime < voiceDuration - 0.08) return
pause()
audioPause()
if (playToken != null) onPlayTokenChange?.(null)
}, [
isPlaying,
displayCurrentTime,
voiceAudioUrl,
voiceDuration,
pause,
audioPause,
playToken,
onPlayTokenChange,
])
// ── 未就绪 ──
if (!ready || !assets.length) {
return (
<div
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
...
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
</p>
</div>
)
}
// ── 无播放片段 ──
if (!canPlay) {
return (
<div
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
</p>
</div>
)
}
return (
<div
ref={playerContainerRef}
style={{
position: "relative",
width: "100%",
maxWidth: compact ? "100%" : 280,
margin: compact ? 0 : "0 auto",
aspectRatio: "9 / 16",
background: compact ? "transparent" : "#0a0a0a",
borderRadius: compact ? 10 : 24,
overflow: "hidden",
boxShadow: compact
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
}}
>
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}}
muted={hasVoice || muted}
playsInline
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 5,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
width: `${100 - 2 * titleSidePct}%`,
maxWidth: `${100 - 2 * titleSidePct}%`,
...(customTitleXPct != null && customTitleYPct != null
? {
left: `${customTitleXPct}%`,
top: `${customTitleYPct}%`,
transform: "translate(-50%, -50%)",
textAlign: "center" as const,
}
: {
left: "50%",
transform: "translateX(-50%)",
textAlign: "center" as const,
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translate(-50%, -50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}}
ref={titleDragRef}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
<span
style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
lineHeight: titleSettings.lineHeight ?? 1.05,
wordBreak: "break-word",
WebkitTextStroke: titleSettings.stroke
? `${titleStrokeWidth}px ${titleStrokeColor}`
: undefined,
textShadow: titleSettings.shadow ? titleShadowStr : undefined,
background: titleBgEnabled ? titleBgColor : undefined,
padding: titleBgEnabled
? `${titleBgPadding}px ${titleBgPadding * 1.5}px`
: undefined,
borderRadius: titleBgEnabled ? `${titleBgRadius}px` : undefined,
display: titleBgEnabled ? "inline-block" : undefined,
}}
>
{(effectiveTitle || "").split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</span>
</div>
</div>
)}
<PreviewControls
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
muted={muted}
onToggleMute={() => setMuted((m) => !m)}
hasSegments={segments.length > 0}
segmentIndex={currentSegmentIndex}
segmentCount={segments.length}
currentTime={displayCurrentTime}
totalDuration={effectiveTotalDuration}
onSeek={handleSeekTo}
compact={compact}
/>
</div>
)
}
export default FrontendPreviewPlayer
@@ -5,7 +5,7 @@
* 步骤 1~3:上一步 / 下一步
* 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
* 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」
* 步骤 5(选择封面):上一步,无主按钮
* 步骤 5(选择封面):上一步 + 完成按钮
*/
import React from "react"
import { Tooltip } from "antd"
@@ -32,6 +32,10 @@ export interface GenerateStepActionsProps {
freeClipsUsedThisTime?: number
/** 前往充值 */
onRecharge?: () => void
/** Step5 点击完成(保存入库并跳转) */
onFinish?: () => void | Promise<void>
/** Step5 保存中 */
finishing?: boolean
}
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
@@ -48,6 +52,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
insufficientReason,
freeClipsUsedThisTime,
onRecharge,
onFinish,
finishing,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步 */
@@ -126,8 +132,18 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 5(封面,最后一步):无主按钮 */
return null
/* 步骤 5(封面,最后一步):完成按钮 */
return (
<button
className="xx-btn xx-btn-primary"
onClick={() => {
if (onFinish && !finishing) void onFinish()
}}
disabled={finishing}
>
{finishing ? "⏳ 保存中…" : "✅ 完成"}
</button>
)
}
return (
@@ -4,10 +4,8 @@
* 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。
*/
import React from "react"
import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import type { ScriptItem } from "@/api/scripts"
import Step1EditMode from "./Step1EditMode"
import type { EditMode } from "./Step1EditMode"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
@@ -69,7 +67,6 @@ export interface GenerateStepContentProps {
/* ── 配音 ── */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/* ── 生成 ── */
generating: boolean
generated: boolean
@@ -92,10 +89,6 @@ export interface GenerateStepContentProps {
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
selectedVariantIds?: number[]
/* ── 摘要信息(#1970 Step4 展示用) ── */
selectedScript: ScriptItem | null
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -137,7 +130,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
titlePresets,
coverSettings,
onCoverSettingsChange,
onServerClipsChange,
generating,
generateError,
progress,
@@ -150,18 +142,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedScript,
ttsVoiceId,
ttsVoiceSource,
} = props
const handleClipsChange = React.useCallback(
(clips: EditPlanClip[], _templateId?: string) => {
onServerClipsChange(clips)
},
[onServerClipsChange],
)
switch (currentStep) {
case 1:
return (
@@ -189,7 +171,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
clipCount={clipCount}
onClipCountChange={onClipCountChange}
selectedTemplate={selectedTemplate}
onServerClipsChange={handleClipsChange}
/>
)
case 3:
@@ -219,40 +200,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 4:
return (
<div className="xx-form-section">
{/* 配置摘要(#1970 */}
<div
style={{
padding: 14,
background: "#f9fafb",
borderRadius: 8,
marginBottom: 16,
fontSize: 13,
lineHeight: 1.8,
color: "#374151",
}}
>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
📋
</div>
<div>🎬 {editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
{editMode === "random" ? (
<div>🎙 </div>
) : (
<>
<div>📝 {selectedScript?.title ?? "未选择"}</div>
<div>
🎙
{ttsVoiceId
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}${ttsVoiceId.slice(0, 8)}...`
: "未选择"}
</div>
</>
)}
<div>📱 {videoRatio}</div>
<div>🎯 {dedupEnabled ? "已开启" : "已关闭"}</div>
{previewCount > 1 && <div>📦 {previewCount} </div>}
</div>
{previewCount > 1 ? (
<BatchGenerationGrid
tasks={batchTasks}
@@ -1,195 +0,0 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import { PreviewProgressBar } from "./PreviewProgressBar"
interface PreviewControlsProps {
isPlaying: boolean
onTogglePlay: () => void
muted: boolean
onToggleMute: () => void
hasSegments: boolean
segmentIndex: number
segmentCount: number
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
*/
export const PreviewControls: React.FC<PreviewControlsProps> = ({
isPlaying,
onTogglePlay,
muted,
onToggleMute,
hasSegments,
segmentIndex,
segmentCount,
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
return (
<>
{/* 静音/有声切换(#1741):左上角 */}
{hasSegments && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={onToggleMute}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${segmentIndex + 1} / ${segmentCount}`}
</div>
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={onTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={onTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<PreviewProgressBar
currentTime={currentTime}
totalDuration={totalDuration}
onSeek={onSeek}
compact={compact}
/>
</div>
</>
)
}
@@ -1,108 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import { formatDuration } from "../utils/formatDuration"
interface PreviewProgressBarProps {
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 进度条组件:点击/拖拽 seek
*/
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const seekByClientX = useCallback(
(clientX: number) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
onSeek(ratio * totalDuration)
},
[totalDuration, onSeek],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
seekByClientX(e.clientX)
},
[seekByClientX],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, seekByClientX])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
return (
<>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</>
)
}
@@ -1,24 +1,18 @@
/**
* Step 4 选择标题(Issue #1677 批量生成)
*
* 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格),
* 右侧边栏标题设置。本组件渲染在右侧边栏:
* - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成
* (一次生成 N 个标题,分别填入各变体,可单独换一个)
* 布局:全宽区域,标题输入 + 标题模板卡片网格。
* - 单视频:TitleLibraryAutoComplete 标题库输入
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import React, { useMemo } from "react"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS } from "../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
import TitleStylePanel from "./title/TitleStylePanel"
import { AI_TITLE_TEMPLATES } from "../constants"
import type { TitleTemplate } from "@/components/title/template-types"
interface Step4TitleSettingsProps {
@@ -55,36 +49,6 @@ interface Step4TitleSettingsProps {
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
}
/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */
function buildBatchAiTitles(topic: string, count: number): string[] {
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const pool: string[] = []
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style] || []
templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic)))
})
// 洗牌后取前 count 个;不足则轮转补齐
const shuffled = [...pool].sort(() => Math.random() - 0.5)
const out: string[] = []
for (let i = 0; i < count; i++) {
out.push(shuffled[i % shuffled.length] || "")
}
return out
}
function extractTopic(text: string): string {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
return keywords.slice(0, 3).join("")
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
const t = useStep4Title(props)
const {
@@ -108,8 +72,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
} = props
const isBatch = previewCount > 1
const [batchAiLoading, setBatchAiLoading] = useState(false)
const [batchAiTopic, setBatchAiTopic] = useState("")
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
const updateVariantTitle = (index: number, val: string) => {
@@ -122,117 +84,31 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
}
}
/** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */
const handleBatchAiGenerate = async (onlyEmpty = false) => {
if (!onPreviewTitlesChange || !previewTitles) return
const topic = (batchAiTopic || t.aiTitleInput || "").trim()
if (!topic) {
message.warning("请先输入主题词,例如:萌宠日常、旅行vlog")
return
}
setBatchAiLoading(true)
try {
// 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟)
await new Promise((resolve) => setTimeout(resolve, 800))
const picked = buildBatchAiTitles(extractTopic(topic), previewCount)
const next = [...previewTitles]
for (let i = 0; i < previewCount; i++) {
if (onlyEmpty && next[i]?.trim()) continue
if (picked[i]) next[i] = picked[i]
}
onPreviewTitlesChange(next)
if (next[0]) t.updateTitle(next[0])
message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`)
} finally {
setBatchAiLoading(false)
}
}
const titleOptions = useMemo(
() => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })),
[t.userTitles],
)
return (
<div className="xx-form-section xx-title-sidebar">
<div className="xx-form-section">
<h3>📝 </h3>
{!isBatch ? (
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
<>
{t.titleSettings.aiAutoSelect ? (
<>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label> AI </label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄
</button>
</div>
</div>
</>
) : (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label></label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
</>
)}
</>
/* ── 单视频:标题输入框 ── */
<div className="xx-form-field" style={{ maxWidth: 640 }}>
<label></label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
) : (
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
/* ── 批量:N 个独立标题输入框 ── */
<div className="xx-batch-titles">
<div
style={{
@@ -242,42 +118,11 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
lineHeight: 1.6,
}}
>
//
</div>
{/* 批量 AI 标题 */}
<div className="xx-batch-ai-row">
<Input
placeholder="主题词,如:萌宠日常、旅行vlog"
value={batchAiTopic || t.aiTitleInput}
onChange={(e) => {
setBatchAiTopic(e.target.value)
t.setAiTitleInput(e.target.value)
}}
maxLength={30}
size="small"
style={{ flex: 1 }}
/>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(false)}
>
{batchAiLoading ? <LoadingOutlined /> : "✨"} {previewCount}
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(true)}
>
</button>
//
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i}>
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
<label> {i + 1} </label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
@@ -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,19 @@
import React, { useState } from "react"
import type { CoverTemplate } from "../../types/cover"
import React, { useState, useCallback, useRef } from "react"
import { Slider, Switch, Select, InputNumber } from "antd"
import type {
CoverTemplate,
CoverEditorConfig,
TextStyleConfig,
TextBackground,
TextDirection,
StrokeStyle,
TextBgShape,
} 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,18 +21,375 @@ 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={0.1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x: v ?? 0, y })}
style={{ width: "100%" }}
/>
<InputNumber
size="small"
value={y}
step={0.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 })
const updBg = <K extends keyof TextBackground>(key: K, val: TextBackground[K]) =>
upd("background", { ...config.background, [key]: val })
return (
<div className="xx-ce-text-panel">
{/* 文字内容 */}
<div className="xx-ce-row">
<div className="xx-ce-readonly-text">{config.text || placeholder}</div>
<div className="xx-ce-hint"></div>
</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"
/>
<div className="xx-ce-hint">绿= | =35</div>
</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={{ 100: "100", 500: "500", 700: "700", 900: "900", 1000: "1000" }}
/>
</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)}
>
{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-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 className="xx-ce-row">
<label className="xx-ce-label"></label>
<div className="xx-ce-shadow-actions">
<button
className="xx-ce-add-shadow-btn"
onClick={() =>
upd("shadows", [
...config.shadows,
{ color: "#000000", offsetX: 2, offsetY: 2, blur: 4 },
])
}
>
+
</button>
<button className="xx-ce-preset-shadow-btn"></button>
</div>
<div className="xx-ce-sub-row">
<label className="xx-ce-label"></label>
<Switch
size="small"
checked={config.traditionalShadow}
onChange={(v) => upd("traditionalShadow", v)}
/>
</div>
</div>
{/* 位置 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></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={0}
max={360}
step={1}
value={config.rotation}
onChange={(v) => upd("rotation", v)}
/>
</div>
{/* 文字背景 */}
<div className="xx-ce-row">
<div className="xx-ce-switch-row">
<label className="xx-ce-label"></label>
<Switch
size="small"
checked={config.background.enabled}
onChange={(v) => updBg("enabled", v)}
/>
</div>
</div>
{config.background.enabled && (
<div className="xx-ce-text-bg-section">
{/* 背景颜色 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<ColorPicker value={config.background.color} onChange={(v) => updBg("color", v)} />
</div>
{/* 透明度 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.background.opacity}%</label>
<Slider
min={0}
max={100}
step={1}
value={config.background.opacity}
onChange={(v) => updBg("opacity", v)}
/>
</div>
{/* 形状 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<Select
value={config.background.shape}
onChange={(v) => updBg("shape", v as TextBgShape)}
style={{ width: "100%" }}
options={[
{ label: "矩形", value: "rectangle" },
{ label: "自定义多边形", value: "polygon" },
]}
/>
</div>
{config.background.shape === "polygon" && (
<div className="xx-ce-hint">提示: 点击背景区域添加顶点</div>
)}
{/* 大小 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<div className="xx-ce-position">
<InputNumber
size="small"
value={config.background.width}
step={0.1}
suffix="%"
controls={false}
onChange={(v) => updBg("width", v ?? 0)}
style={{ width: "100%" }}
/>
<InputNumber
size="small"
value={config.background.height}
step={0.1}
suffix="%"
controls={false}
onChange={(v) => updBg("height", v ?? 0)}
style={{ width: "100%" }}
/>
</div>
</div>
{/* 位置偏移 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={config.background.posX}
y={config.background.posY}
onChange={(pos) => {
updBg("posX", pos.x)
updBg("posY", pos.y)
}}
/>
</div>
{/* 旋转角度 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.background.rotation}°</label>
<Slider
min={0}
max={360}
step={1}
value={config.background.rotation}
onChange={(v) => updBg("rotation", v)}
/>
</div>
</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,
@@ -29,34 +397,90 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
subtitle: true,
mask: false,
})
const fileRef = useRef<HTMLInputElement>(null)
const toggleSection = (key: keyof SectionState) => {
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
}
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 handleSave = () => {
if (!template) return
onSave({ ...template, name })
const result: CoverTemplate = template
? { ...template, name, config: cfg }
: {
id: "",
name,
is_system: false,
thumbnail_url: "",
created_at: "",
config: cfg,
}
onSave(result)
onClose()
}
/* ── canvas helpers ── */
const scaleFont = (px: number) => Math.round((px / 1200) * 225 * 100) / 100
const renderTextStyle = (tc: TextStyleConfig): React.CSSProperties => {
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: scaleFont(tc.fontSize),
fontWeight: tc.fontWeight,
color: tc.color,
letterSpacing: scaleFont(tc.letterSpacing),
lineHeight: scaleFont(tc.lineHeight),
WebkitTextStroke:
tc.strokeWidth > 0 ? `${scaleFont(tc.strokeWidth)}px ${tc.strokeColor}` : undefined,
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "nowrap",
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
zIndex: 3,
}
if (tc.shadows.length > 0) {
style.textShadow = tc.shadows
.map(
(s) =>
`${scaleFont(s.offsetX)}px ${scaleFont(s.offsetY)}px ${scaleFont(s.blur)}px ${s.color}`,
)
.join(", ")
}
return style
}
return (
<Modal
open={open}
onCancel={onClose}
width={1000}
width={1050}
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="输入模板名称"
/>
<div className="xx-cover-editor-header-actions">
<div className="xx-ce-header-actions">
<Button buttonType="ghost" onClick={onClose}>
</Button>
@@ -66,55 +490,425 @@ 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.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 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((s) => (
<button
key={s}
className={`xx-ce-radio-btn ${cfg.personStrokeStyle === s ? "active" : ""}`}
onClick={() => upd("personStrokeStyle", s)}
>
{s === "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 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={1}
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={1}
max={20}
step={1}
value={cfg.subtitleMaxChars}
onChange={(v) => upd("subtitleMaxChars", v)}
/>
</div>
</>
)}
</div>
</div>
)}
</div>
{/* 2. 人像设置 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("portrait")}>
<span></span>
<div className="xx-ce-header-right">
<span className="xx-ce-status-text"></span>
<Switch
size="small"
checked={cfg.portraitEnabled}
onChange={(v) => upd("portraitEnabled", v)}
onClick={(_, e) => e.stopPropagation()}
/>
</div>
</div>
))}
{sections.portrait && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.portraitSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.portraitSize}
onChange={(v) => upd("portraitSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={cfg.portraitPosition.x}
y={cfg.portraitPosition.y}
onChange={(pos) => upd("portraitPosition", pos)}
/>
</div>
</div>
)}
</div>
{/* 3. 背景设置 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("background")}>
<span></span>
<div className="xx-ce-header-right">
<span className="xx-ce-status-text"></span>
<Switch
size="small"
checked={cfg.backgroundEnabled}
onChange={(v) => upd("backgroundEnabled", v)}
onClick={(_, e) => e.stopPropagation()}
/>
</div>
</div>
{sections.background && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.backgroundSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.backgroundSize}
onChange={(v) => upd("backgroundSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={cfg.backgroundPosition.x}
y={cfg.backgroundPosition.y}
onChange={(pos) => upd("backgroundPosition", pos)}
/>
</div>
</div>
)}
</div>
{/* 4. 主标题 */}
<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>
{/* 5. 副标题 */}
<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>
{/* 6. 蒙版 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("mask")}>
<span></span>
<div className="xx-ce-header-right">
<span className="xx-ce-status-text"></span>
<Switch
size="small"
checked={cfg.maskEnabled}
onChange={(v) => upd("maskEnabled", v)}
onClick={(_, e) => e.stopPropagation()}
/>
</div>
</div>
{sections.mask && (
<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" 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">: {cfg.maskSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.maskSize}
onChange={(v) => upd("maskSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={cfg.maskPosition.x}
y={cfg.maskPosition.y}
onChange={(pos) => upd("maskPosition", pos)}
/>
</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"></label>
<Select
value={cfg.maskShape}
onChange={(v) => upd("maskShape", v)}
style={{ width: "100%" }}
options={[
{ label: "矩形", value: "矩形" },
{ label: "圆形", value: "圆形" },
{ label: "渐变", value: "渐变" },
]}
/>
</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">
{/* decorative anchor dots */}
<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">
{/* Background */}
{cfg.backgroundEnabled && (
<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%)",
}}
/>
)}
{/* Portrait placeholder */}
{cfg.portraitEnabled && (
<div
className="xx-ce-el-portrait"
style={{
width: `${cfg.portraitSize}%`,
height: `${cfg.portraitSize * 0.6}%`,
left: `${cfg.portraitPosition.x}%`,
top: `${cfg.portraitPosition.y}%`,
transform: "translate(-50%, -50%)",
borderStyle: cfg.personStrokeEnabled ? cfg.personStrokeStyle : "solid",
borderColor: cfg.personStrokeEnabled ? cfg.personStrokeColor : "#333",
borderWidth: cfg.personStrokeEnabled
? `${Math.max(1, cfg.personStrokeWidth / 4)}px`
: "2px",
filter: cfg.blurEnabled ? `blur(${cfg.blurAmount / 4}px)` : undefined,
}}
>
{/* 8 handles */}
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
<span key={i} className={`xx-ce-handle xx-ce-handle--${i}`} />
))}
</div>
)}
{/* Title */}
<div style={renderTextStyle(cfg.title)}>
{cfg.title.background.enabled && (
<div
className="xx-ce-text-bg"
style={{
backgroundColor: cfg.title.background.color,
opacity: cfg.title.background.opacity / 100,
width: `${cfg.title.background.width}%`,
height: `${cfg.title.background.height}%`,
left: `${cfg.title.background.posX}%`,
top: `${cfg.title.background.posY}%`,
transform: `translate(-50%, -50%) rotate(${cfg.title.background.rotation}deg)`,
position: "absolute",
border:
cfg.title.background.shape === "polygon" ? "1px dashed #999" : undefined,
}}
/>
)}
{cfg.title.text || "主标题文字"}
</div>
{/* Subtitle */}
<div style={renderTextStyle(cfg.subtitle)}>
{cfg.subtitle.background.enabled && (
<div
className="xx-ce-text-bg"
style={{
backgroundColor: cfg.subtitle.background.color,
opacity: cfg.subtitle.background.opacity / 100,
width: `${cfg.subtitle.background.width}%`,
height: `${cfg.subtitle.background.height}%`,
left: `${cfg.subtitle.background.posX}%`,
top: `${cfg.subtitle.background.posY}%`,
transform: `translate(-50%, -50%) rotate(${cfg.subtitle.background.rotation}deg)`,
position: "absolute",
}}
/>
)}
{cfg.subtitle.text || "副标题文字"}
</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>
</div>
@@ -1,35 +0,0 @@
/**
* 单个 AI 标题卡片
*/
import React from "react"
import { CheckCircleFilled } from "@ant-design/icons"
interface AiTitleCardProps {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
selected: boolean
onClick: () => void
}
const AiTitleCard: React.FC<AiTitleCardProps> = ({
title,
highlight,
style,
selected,
onClick,
}) => {
return (
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
<div className="xx-ai-title-card-text">{title}</div>
<div className="xx-ai-title-card-tag">{highlight}</div>
{selected && (
<div className="xx-ai-title-card-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
</div>
)}
</div>
)
}
export default AiTitleCard
@@ -1,105 +0,0 @@
/**
* AI 智能生成标题
* 输入框 + 生成按钮 + 结果列表 + 加载状态
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import AiTitleCard from "./AiTitleCard"
interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
interface AiTitleGeneratorProps {
inputValue: string
onInputChange: (value: string) => void
generating: boolean
onGenerate: () => void
results: AiTitleItem[]
hasGenerated: boolean
onSelect: (title: string) => void
selectedTitle: string
onRefresh: () => void
}
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
inputValue,
onInputChange,
generating,
onGenerate,
results,
hasGenerated,
onSelect,
selectedTitle,
onRefresh,
}) => {
return (
<div className="xx-ai-title-section">
<div className="xx-ai-title-header">
<span className="xx-ai-title-label"> AI </span>
</div>
<div className="xx-ai-title-input-row">
<input
className="xx-ai-title-input"
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onGenerate()
}}
/>
<button
type="button"
className="xx-btn xx-btn-primary"
onClick={onGenerate}
disabled={generating || !inputValue.trim()}
>
{generating ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
</>
) : (
"生成标题"
)}
</button>
</div>
{/* 生成结果 */}
{hasGenerated && !generating && results.length > 0 && (
<div className="xx-ai-title-results">
<div className="xx-ai-title-results-header">
<span className="xx-ai-title-results-count"> {results.length} </span>
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
🔄
</button>
</div>
<div className="xx-ai-title-list">
{results.map((item, idx) => (
<AiTitleCard
key={idx}
title={item.title}
highlight={item.highlight}
style={item.style}
selected={selectedTitle === item.title}
onClick={() => onSelect(item.title)}
/>
))}
</div>
</div>
)}
{/* 生成中 */}
{generating && (
<div className="xx-ai-title-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
AI
</div>
)}
</div>
)
}
export default AiTitleGenerator
@@ -34,9 +34,14 @@ import { buildPresetPreviewSettings } from "@/components/title/utils"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "@/components/title/TitleTemplateEditor"
import { useTitleTemplates } from "@/components/title/useTitleTemplates"
import { titleStyleConfigToCamel, templateToPreviewSettings } from "@/components/title/utils"
import {
titleStyleConfigToCamel,
templateToPreviewSettings,
camelToTitleStyleConfig,
} from "@/components/title/utils"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../types"
import type { TitleTemplate } from "@/components/title/template-types"
import type { TitleStyleConfig } from "@/components/title/types"
import "./TitleStylePanel.css"
import "@/components/title/TitleTemplate.css"
@@ -146,13 +151,20 @@ const ColorPicker: React.FC<{
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
onChange(e.target.value)
setCustomOpen(false)
}}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: 0,
height: 0,
width: customOpen ? 28 : 0,
height: customOpen ? 28 : 0,
border: "none",
padding: 0,
cursor: "pointer",
background: "transparent",
}}
/>
</div>
@@ -231,8 +243,14 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
/* ── 模板相关 hook + 编辑器 state ── */
const { templates, createTemplate, duplicateTemplate, deleteTemplate, exportTemplate } =
useTitleTemplates()
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
@@ -246,6 +264,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
const handleRequestCreate = useCallback(() => {
// 新建模板:若已有选中模板,以其样式为起点;否则以当前 settings 为起点
// 注意:这里只构造「草稿」对象(不写入 localStorage),保存时再由 handleEditorSave 真正 createTemplate
let base: TitleSettings = settings
if (selectedTemplateId) {
const sel = templates.find((t) => t.id === selectedTemplateId)
@@ -259,40 +278,21 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
}
}
}
const t = createTemplate({
const draft: TitleTemplate = {
id: "", // 空 id 表示新建草稿
name: "我的标题模板",
style: {
font: base.font,
size: base.size,
color: base.color,
bold: base.bold,
italic: base.italic,
stroke: base.stroke,
stroke_width: base.strokeWidth ?? 4,
stroke_color: base.strokeColor ?? "#000000",
shadow: base.shadow,
shadow_offset_x: base.shadowOffsetX ?? 2,
shadow_offset_y: base.shadowOffsetY ?? 2,
shadow_blur: base.shadowBlur ?? 4,
shadow_color: base.shadowColor ?? "rgba(0,0,0,0.8)",
bg_enabled: base.bgEnabled,
bg_color: base.bgColor,
bg_padding: base.bgPadding,
bg_radius: base.bgRadius,
line_height: base.lineHeight,
margin_top: base.marginTop,
max_chars_per_line: base.maxCharsPerLine,
position: (base.position === "custom" ? "bottom" : base.position) as
"top" | "center" | "bottom" | "custom",
pos_x: base.posX ?? undefined,
pos_y: base.posY ?? undefined,
line_overrides: [],
cover_title_config: null,
},
})
setEditingTemplate(t)
emoji: "✨",
isBuiltin: false,
style: camelToTitleStyleConfig({
...base,
position: base.position === "custom" ? "bottom" : base.position,
} as TitleSettings),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setEditingTemplate(draft)
setEditorOpen(true)
}, [createTemplate, settings, selectedTemplateId, templates])
}, [settings, selectedTemplateId, templates])
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
setEditingTemplate(tpl)
@@ -342,14 +342,34 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
[onApplyTemplate, settings],
)
const handleEditorSaved = useCallback(
(tpl: TitleTemplate) => {
const handleEditorSave = useCallback(
(data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => {
if (!editingTemplate) return
let saved: TitleTemplate
if (editingTemplate.isBuiltin || !editingTemplate.id) {
// 系统模板编辑 → 创建副本;新草稿(空 id)→ 新建
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
} else {
// 已有自定义模板 → 更新
updateTemplate(editingTemplate.id, {
name: data.name,
emoji: data.emoji,
style: data.style,
})
saved = {
...editingTemplate,
name: data.name,
emoji: data.emoji,
style: data.style,
updatedAt: new Date().toISOString(),
}
}
setEditorOpen(false)
setEditingTemplate(null)
message.success(`已保存:${tpl.name}`)
handleSelectTemplate(tpl)
message.success(`已保存:${data.name}`)
handleSelectTemplate(saved)
},
[handleSelectTemplate],
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
)
/* ── 渲染模板大卡片(v3:图片背景 + 透明 Canvas 叠字 + 底栏始终可见操作按钮) ── */
@@ -811,7 +831,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
setEditorOpen(false)
setEditingTemplate(null)
}}
onSaved={handleEditorSaved}
onSave={handleEditorSave}
/>
)}
</div>
@@ -854,7 +874,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
setEditorOpen(false)
setEditingTemplate(null)
}}
onSaved={handleEditorSaved}
onSave={handleEditorSave}
/>
)}
</div>
-25
View File
@@ -535,31 +535,6 @@ export const SMART_MATCH_REASONS = [
"人物表情生动",
]
/* ── AI 标题模板 ── */
export const AI_TITLE_TEMPLATES: Record<string, string[]> = {
catchy: [
"震惊!{topic}居然还能这样操作",
"99%的人都不知道的{topic}秘诀",
"{topic}的终极指南,看完直接封神",
"别再走弯路了!{topic}看这一篇就够",
"一个视频讲透{topic},建议收藏",
],
emotional: [
"致每一个在{topic}路上坚持的人",
"关于{topic},我想说句真心话",
"{topic}背后的故事,看完沉默了",
"为什么我劝你一定要了解{topic}",
"这才是{topic}最动人的样子",
],
informative: [
"{topic}完整科普:从入门到精通",
"深度解析{topic}的核心原理",
"{topic}行业趋势报告|2026最新版",
"三分钟带你全面了解{topic}",
"{topic}常见问题与解决方案汇总",
],
}
/* ── 默认封面设置 ── */
export const DEFAULT_COVER_SETTINGS: CoverConfig = {
enabled: true,
+385 -150
View File
@@ -2880,27 +2880,6 @@
Issue #1677 多视频批量生成
================================================================ */
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr 380px;
align-items: start;
}
.xx-generate-preview-col {
min-width: 0;
position: sticky;
top: 16px;
}
.xx-generate-preview-col .xx-form-section {
margin: 0;
}
.xx-title-sidebar {
max-height: calc(100vh - 140px);
overflow-y: auto;
}
/* ── 数量选择弹窗 ── */
.xx-modal-mask {
position: fixed;
@@ -3240,135 +3219,6 @@
border-radius: 4px;
}
/* ── 响应式:窄屏 Step4 回退单列 ── */
@media (max-width: 960px) {
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr;
}
.xx-generate-preview-col {
position: static;
}
.xx-title-sidebar {
max-height: none;
}
}
/* ============================================================
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
============================================================ */
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
.xx-canvas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
gap: 14px;
}
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
@media (max-width: 960px) {
.xx-canvas-grid {
grid-template-columns: minmax(0, 320px);
}
}
.xx-canvas-grid-card {
position: relative;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
background: transparent;
transition: border-color 0.2s ease;
min-width: 0;
}
.xx-canvas-grid-card.selected {
border-color: var(--primary-color, #1677ff);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
}
.xx-canvas-grid-card-bar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
padding: 6px 10px;
background: var(--bg-surface, #fff);
border-bottom: 1px solid var(--border-primary, #e2e8f0);
}
.xx-canvas-grid-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
cursor: pointer;
user-select: none;
}
.xx-canvas-grid-check input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary-color, #1677ff);
}
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
.xx-variant-clips-status {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
max-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
background: #0a0a0a;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 20px 50px -12px rgba(0, 0, 0, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.xx-variant-clips-status .anticon {
font-size: 22px;
}
.xx-variant-clips-error-text {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
}
/* ============================================================
批量标题:AI 一键生成行(Issue #1677
============================================================ */
.xx-batch-ai-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: var(--bg-secondary, #f7f8fa);
border: 1px dashed var(--border-primary, #d9d9d9);
border-radius: 10px;
}
.xx-batch-ai-row .xx-form-field {
margin: 0;
flex: 1;
min-width: 140px;
}
.xx-batch-titles {
display: flex;
flex-direction: column;
@@ -3485,3 +3335,388 @@
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;
}
/* Antd Slider overrides for editor */
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
/* Antd Select dropdown font dots */
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
/* Canvas text elements — ensure proper stacking */
.xx-ce-canvas > div {
pointer-events: none;
}
@@ -10,7 +10,7 @@ export interface BatchTaskState {
taskId: string
/** 变体序号(0-based,与标题/封面数组对齐) */
variantIndex: number
status: "running" | "completed" | "failed"
status: "running" | "completed" | "awaiting_cover" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
@@ -96,7 +96,7 @@ export function useGenerationPolling({
runId: number,
callbacks?: {
onTaskProgress?: (pct: number) => void
onTaskCompleted?: (videos: unknown[]) => void
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onTaskFailed?: (msg: string) => void
},
): Promise<unknown[]> => {
@@ -111,7 +111,7 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed") {
if (task.status === "completed" || task.status === "awaiting_cover") {
done = true
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
@@ -121,7 +121,7 @@ export function useGenerationPolling({
reject(new Error(msg))
return
}
callbacks?.onTaskCompleted?.(videos)
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
resolve(videos)
return
}
@@ -259,10 +259,11 @@ export function useGenerationPolling({
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
reportAggregateProgress()
},
onTaskCompleted: (videos) => {
onTaskCompleted: (videos, taskStatus) => {
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
},
@@ -293,8 +294,9 @@ export function useGenerationPolling({
}
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
onTaskCompleted: (videos) => {
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
onTaskCompleted: (videos, taskStatus) => {
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
message.success(`视频 ${variantIndex + 1} 重试成功`)
},
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
}
export function useBatchCovers({
selectedTemplate,
selectedTemplate: _selectedTemplate,
generatedVideos,
titles,
titleStyle,
@@ -93,7 +93,12 @@ export function useBatchCovers({
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" ||
v.status === "awaiting_cover" ||
v.status === "awaiting_cover",
)
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
@@ -102,7 +107,7 @@ export function useBatchCovers({
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
const response = await generateCover("default", {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
@@ -166,7 +171,7 @@ export function useBatchCovers({
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
@@ -203,7 +208,10 @@ export function useBatchCovers({
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
)
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
@@ -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)
}
})
@@ -1,155 +0,0 @@
/**
* 预览素材加载 Hook
* 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等)
* 供前端预览播放器使用
*
* 注意:后端没有批量接口(/assets/batch 返回 405),
* 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id}
*/
import { useState, useEffect, useCallback, useRef } from "react"
import type { AssetItem } from "@/api/assets"
import type { AxiosResponse } from "axios"
/**
* 通过 ID 列表逐个获取素材(并发)
* 使用 Promise.allSettled 确保单个失败不影响整体
*/
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
// 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
if (!validIds.length) return []
try {
const { default: apiClient } = await import("@/api/client")
const results = await Promise.allSettled(
validIds.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
)
return results
.filter(
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
r.status === "fulfilled" && !!r.value?.data,
)
.map((r) => r.value.data)
} catch {
return []
}
}
interface UsePreviewAssetsReturn {
/** 加载后的素材列表 */
assets: AssetItem[]
/** 是否正在加载 */
loading: boolean
/** 是否已就绪(加载完成) */
ready: boolean
/** 手动触发重新加载 */
reload: () => void
/**
* 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材
* (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并,
* 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。
*/
ensureAssets: (ids: string[]) => Promise<void>
}
/**
* usePreviewAssets — 加载选中素材的视频文件信息
*/
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
const [assets, setAssets] = useState<AssetItem[]>([])
const [loading, setLoading] = useState(false)
const [ready, setReady] = useState(false)
const requestIdRef = useRef(0)
// 稳定化 assetIds:只有内容真正变化时才更新引用
const stableAssetIds = useStableArray(assetIds)
const load = useCallback(async () => {
const validIds = stableAssetIds.filter(
(id): id is string => typeof id === "string" && id.length > 0,
)
if (!validIds.length || !enabled) {
setAssets([])
setReady(false)
return
}
const thisRequestId = ++requestIdRef.current
setLoading(true)
setReady(false)
try {
const result = await fetchAssetsByIds(validIds)
// 防止竞态:只保留最新请求的结果
if (requestIdRef.current === thisRequestId) {
setAssets(result)
setReady(result.length > 0)
}
} catch {
if (requestIdRef.current === thisRequestId) {
setAssets([])
setReady(false)
}
} finally {
if (requestIdRef.current === thisRequestId) {
setLoading(false)
}
}
}, [stableAssetIds, enabled])
// 补拉用独立序号:不干扰主 load 的竞态守卫
const ensureSeqRef = useRef(0)
const assetsRef = useRef<AssetItem[]>([])
useEffect(() => {
assetsRef.current = assets
}, [assets])
/**
* 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时,
* 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。
*/
const ensureAssets = useCallback(async (ids: string[]) => {
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
if (!validIds.length) return
const seq = ++ensureSeqRef.current
const missing = Array.from(new Set(validIds)).filter(
(id) => !assetsRef.current.some((a) => a.id === id),
)
if (!missing.length) return
const fetched = await fetchAssetsByIds(missing)
if (seq !== ensureSeqRef.current || !fetched.length) return
const existing = new Set(assetsRef.current.map((a) => a.id))
const additions = fetched.filter((a) => !existing.has(a.id))
if (!additions.length) return
const merged = [...assetsRef.current, ...additions]
assetsRef.current = merged
setAssets(merged)
setReady(merged.length > 0)
}, [])
useEffect(() => {
load()
}, [load])
return { assets, loading, ready, reload: load, ensureAssets }
}
/**
* useStableArray — 数组内容稳定化 Hook
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
*/
function useStableArray<T>(array: T[]): T[] {
const ref = useRef<T[]>(array)
// 比较数组内容是否真正变化
const hasChanged =
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
if (hasChanged) {
ref.current = array
}
return ref.current
}
export default usePreviewAssets
@@ -1,131 +0,0 @@
import { useCallback, useEffect, useRef } from "react"
interface UsePreviewAudioOptions {
voiceAudioUrl: string | undefined
voiceDurationHint: number | undefined
muted: boolean
isPlaying: boolean
currentTime: number
onVoiceDurationChange: (d: number) => void
onEnded: () => void
}
interface UsePreviewAudioReturn {
seekTo: (time: number) => void
ensurePlayingAt: (time: number) => void
pause: () => void
}
/**
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
* ended 事件回调、seek 同步、末帧冻结期间续播。
*/
export function usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange,
onEnded,
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 外部 hint 初始化(自测值前的兜底)
useEffect(() => {
if (voiceDurationHint && voiceDurationHint > 0) {
onVoiceDurationChange(voiceDurationHint)
}
}, [voiceDurationHint, onVoiceDurationChange])
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
useEffect(() => {
if (!voiceAudioUrl) {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
audioRef.current = null
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
}
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
audioRef.current.muted = muted
const audio = audioRef.current
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
onVoiceDurationChange(audio.duration)
}
}
const onEndedHandler = () => onEnded()
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEndedHandler)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEndedHandler)
}
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
// mute 变化即时同步
useEffect(() => {
if (audioRef.current) audioRef.current.muted = muted
}, [muted])
// 播放/暂停同步(跟随视频 isPlaying
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
try {
audio.currentTime = currentTime
} catch {
/* ignore */
}
}
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
const seekTo = useCallback((time: number) => {
const audio = audioRef.current
if (audio && audio.src) {
try {
audio.currentTime = time
} catch {
/* ignore */
}
}
}, [])
const ensurePlayingAt = useCallback((time: number) => {
const audio = audioRef.current
if (!audio || !audio.src) return
try {
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
if (audio.paused) audio.play().catch(() => {})
} catch {
/* ignore */
}
}, [])
const pause = useCallback(() => {
try {
audioRef.current?.pause()
} catch {
/* ignore */
}
}, [])
return { seekTo, ensurePlayingAt, pause }
}
@@ -1,384 +0,0 @@
/**
* 素材片段调度器 Hook(多 video 元素方案 v3
*
* v3 修复:
* - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱
* - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔
* - 进度更新 200ms 节流
*/
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
export interface PlaybackSegment {
assetId: string
videoUrl: string
startTime: number
endTime: number
order: number
/** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */
playbackRate?: number
}
export interface SegmentSchedulerState {
isPlaying: boolean
currentTime: number
totalDuration: number
currentSegmentIndex: number
segmentLocalTime: number
isEnded: boolean
canPlay: boolean
play: () => void
pause: () => void
togglePlayPause: () => void
seekTo: (time: number) => void
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
}
function findSegmentAtTime(
segments: PlaybackSegment[],
globalTime: number,
): { index: number; localTime: number } {
let accumulated = 0
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
const segDuration = seg.endTime - seg.startTime
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
}
accumulated += segDuration
}
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
}
function buildTimeline(segments: PlaybackSegment[]): number[] {
const starts: number[] = []
let acc = 0
for (const seg of segments) {
starts.push(acc)
acc += seg.endTime - seg.startTime
}
return starts
}
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
const [isEnded, setIsEnded] = useState(false)
const rafRef = useRef(0)
const isSeekingRef = useRef(false)
const lastTimeUpdateRef = useRef(0)
// 所有动态值存入 ref,tick 始终读取最新值,不依赖闭包
const segIdxRef = useRef(0)
const segmentsRef = useRef(segments)
const timelineStartsData = useMemo(() => buildTimeline(segments), [segments])
const totalDurationData = useMemo(
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
[segments],
)
const timelineStartsRef = useRef(timelineStartsData)
const totalDurationRef = useRef(totalDurationData)
const isPlayingRef = useRef(false)
segmentsRef.current = segments
timelineStartsRef.current = timelineStartsData
totalDurationRef.current = totalDurationData
const canPlay = segments.length > 0
useEffect(() => {
segIdxRef.current = currentSegmentIndex
}, [currentSegmentIndex])
useEffect(() => {
isPlayingRef.current = isPlaying
}, [isPlaying])
const waitForReady = useCallback((video: HTMLVideoElement, timeout = 3000): Promise<void> => {
if (video.readyState >= 3) return Promise.resolve()
return new Promise((resolve) => {
const onCanPlay = () => {
video.removeEventListener("canplay", onCanPlay)
clearTimeout(timer)
resolve()
}
const timer = setTimeout(() => {
video.removeEventListener("canplay", onCanPlay)
resolve()
}, timeout)
video.addEventListener("canplay", onCanPlay)
})
}, [])
const switchToSegment = useCallback(
async (index: number, seekToLocalTime?: number) => {
const segs = segmentsRef.current
const video = videoRefs.current[index]
if (!video || index >= segs.length) return
const seg = segs[index]
const localTime = seekToLocalTime ?? seg.startTime
const oldIdx = segIdxRef.current
const oldVideo = videoRefs.current[oldIdx]
if (oldVideo && oldVideo !== video) oldVideo.pause()
if (!video.src && seg.videoUrl) {
video.src = seg.videoUrl
video.load()
}
if (Math.abs(video.currentTime - localTime) > 0.05) {
video.currentTime = localTime
}
// #1754:按比例调速(配音时长≠clips 总时长时的前端兜底)
const rate = seg.playbackRate || 1
if (Math.abs(video.playbackRate - rate) > 0.01) {
video.playbackRate = rate
}
segIdxRef.current = index
setCurrentSegmentIndex(index)
await waitForReady(video)
},
[waitForReady],
)
// 稳定的 tick 函数,空依赖,所有值从 ref 读取
const tick = useCallback(() => {
const segs = segmentsRef.current
const idx = segIdxRef.current
const video = videoRefs.current[idx]
if (!video || isSeekingRef.current) {
rafRef.current = requestAnimationFrame(tick)
return
}
const seg = segs[idx]
if (!seg) return
// 预加载下一个片段
const nextIndex = idx + 1
if (nextIndex < segs.length) {
const nextVideo = videoRefs.current[nextIndex]
if (nextVideo) {
const timeToEnd = seg.endTime - video.currentTime
if (timeToEnd <= 2 && nextVideo.readyState < 3) {
const nextSeg = segs[nextIndex]
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.5) {
nextVideo.currentTime = nextSeg.startTime
}
}
}
}
// 检测片段边界
if (video.currentTime >= seg.endTime - 0.1) {
if (nextIndex < segs.length) {
const nextVideo = videoRefs.current[nextIndex]
const nextSeg = segs[nextIndex]
const accumulatedTime =
(timelineStartsRef.current[idx] || 0) + (seg.endTime - seg.startTime)
if (nextVideo) {
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.1) {
nextVideo.currentTime = nextSeg.startTime
}
// 先启动下一个视频(muted,可安全同时播放)
nextVideo
.play()
.catch((e) => console.warn("[useSegmentScheduler] next segment play failed:", e))
}
// 立即切换可见性
segIdxRef.current = nextIndex
setCurrentSegmentIndex(nextIndex)
setCurrentTime(accumulatedTime)
lastTimeUpdateRef.current = 0
setIsPlaying(true)
// 下一帧暂停旧视频(让新视频先渲染,避免冻屏)
const oldVideo = video
requestAnimationFrame(() => {
oldVideo.pause()
})
rafRef.current = requestAnimationFrame(tick)
return
} else {
video.pause()
setIsPlaying(false)
setIsEnded(true)
setCurrentTime(totalDurationRef.current)
return
}
}
const globalTime = (timelineStartsRef.current[idx] || 0) + (video.currentTime - seg.startTime)
const now = performance.now()
if (now - lastTimeUpdateRef.current >= 200) {
lastTimeUpdateRef.current = now
setCurrentTime(Math.max(0, Math.min(globalTime, totalDurationRef.current)))
}
rafRef.current = requestAnimationFrame(tick)
}, [])
const play = useCallback(async () => {
if (!canPlay) return
setIsEnded(false)
const idx = segIdxRef.current
const video = videoRefs.current[idx]
if (!video) return
if (idx === 0 && video.readyState < 2) {
if (!video.src && segmentsRef.current[0]?.videoUrl) {
video.src = segmentsRef.current[0].videoUrl
video.load()
}
await waitForReady(video)
}
// 播放前 seek 到片段起始时间,确保 progress 计算正确
const seg = segmentsRef.current[idx]
if (seg && Math.abs(video.currentTime - seg.startTime) > 0.1) {
video.currentTime = seg.startTime
}
// #1754:调速
const rate = seg?.playbackRate || 1
if (Math.abs(video.playbackRate - rate) > 0.01) {
video.playbackRate = rate
}
try {
await video.play()
setIsPlaying(true)
cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(tick)
} catch (err) {
console.warn("[useSegmentScheduler] 播放失败:", err)
}
}, [canPlay, waitForReady, tick])
const pause = useCallback(() => {
const video = videoRefs.current[segIdxRef.current]
if (video) video.pause()
setIsPlaying(false)
cancelAnimationFrame(rafRef.current)
}, [])
const togglePlayPause = useCallback(() => {
if (isPlayingRef.current) {
pause()
} else {
if (isEnded) {
setIsEnded(false)
lastTimeUpdateRef.current = 0
const firstVideo = videoRefs.current[0]
if (firstVideo) {
videoRefs.current.forEach((v, i) => {
if (v && i !== 0) v.pause()
})
firstVideo.currentTime = segmentsRef.current[0]?.startTime || 0
segIdxRef.current = 0
setCurrentSegmentIndex(0)
setCurrentTime(0)
firstVideo
.play()
.then(() => {
setIsPlaying(true)
cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(tick)
})
.catch((e) => console.warn("[useSegmentScheduler] restart failed:", e))
}
} else {
play()
}
}
}, [isEnded, pause, play, tick])
const seekTo = useCallback(
async (time: number) => {
if (!canPlay) return
const clampedTime = Math.max(0, Math.min(time, totalDurationRef.current))
const { index, localTime } = findSegmentAtTime(segmentsRef.current, clampedTime)
isSeekingRef.current = true
cancelAnimationFrame(rafRef.current)
if (index !== segIdxRef.current) {
await switchToSegment(index, localTime)
} else {
const video = videoRefs.current[index]
if (video) video.currentTime = localTime
}
setCurrentTime(clampedTime)
setIsEnded(false)
lastTimeUpdateRef.current = 0
if (isPlayingRef.current) {
const video = videoRefs.current[index]
if (video) {
video.play().catch(() => {})
}
rafRef.current = requestAnimationFrame(tick)
}
setTimeout(() => {
isSeekingRef.current = false
}, 200)
},
[canPlay, switchToSegment, tick],
)
useEffect(() => {
videoRefs.current = videoRefs.current.slice(0, segments.length)
while (videoRefs.current.length < segments.length) {
videoRefs.current.push(null)
}
}, [segments])
useEffect(() => {
return () => {
cancelAnimationFrame(rafRef.current)
}
}, [])
useEffect(() => {
cancelAnimationFrame(rafRef.current)
segIdxRef.current = 0
setIsPlaying(false)
setCurrentTime(0)
setCurrentSegmentIndex(0)
setIsEnded(false)
}, [segments])
const currentSegment = segments[currentSegmentIndex] || null
const segmentLocalTime = currentSegment
? currentTime - (timelineStartsRef.current[currentSegmentIndex] || 0) + currentSegment.startTime
: 0
return {
isPlaying,
currentTime,
totalDuration: totalDurationData,
currentSegmentIndex,
segmentLocalTime,
isEnded,
canPlay,
play,
pause,
togglePlayPause,
seekTo,
videoRefs,
}
}
export default useSegmentScheduler
@@ -1,9 +1,8 @@
import { useEffect, useRef } from "react"
import { useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
// #1894: 标题候选从文案库 scripts[].title 获取,不再调用废弃的 /api/titles
import { getScripts } from "@/api/scripts"
import type { TitleSettings } from "../../types"
import { useAiTitleGenerator } from "./useAiTitleGenerator"
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
import { useDraftAutoSave } from "../useDraftAutoSave"
@@ -16,7 +15,7 @@ interface UseStep4TitleProps {
/**
* Step 4 标题设置 Hook
* 封装 AI 标题生成、标题样式设置等逻辑
* 封装标题样式设置等逻辑
*/
export function useStep4Title({
titleSettings,
@@ -34,26 +33,9 @@ export function useStep4Title({
staleTime: 30_000,
})
// AI 标题生成
const {
aiTitleInput,
setAiTitleInput,
aiTitleGenerating,
aiTitleResults,
hasGeneratedTitles,
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
autoGenerateTitle,
} = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
// 样式更新
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
// 追踪 AI 自动选择开关的上一次值 & 是否首次挂载
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
const isFirstMount = useRef(true)
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
useEffect(() => {
@@ -86,40 +68,12 @@ export function useStep4Title({
scheduleTitleSave,
])
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false
if (titleSettings.aiAutoSelect && !titleSettings.title) {
autoGenerateTitle()
}
prevAiAutoSelect.current = titleSettings.aiAutoSelect
return
}
if (titleSettings.aiAutoSelect && !prevAiAutoSelect.current && !titleSettings.title) {
autoGenerateTitle()
}
prevAiAutoSelect.current = titleSettings.aiAutoSelect
}, [titleSettings.aiAutoSelect, titleSettings.title, autoGenerateTitle])
return {
// 数据
userTitles,
titleSettings,
// AI 标题状态
aiTitleInput,
setAiTitleInput,
aiTitleGenerating,
aiTitleResults,
hasGeneratedTitles,
activePreset: styleUpdaters.activePreset,
titlePresets: styleUpdaters.titlePresets,
// AI 标题操作
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
autoGenerateTitle,
// 标题设置操作
updateTitle: styleUpdaters.updateTitle,
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
@@ -1,124 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { AI_TITLE_TEMPLATES } from "../../constants"
import type { TitleSettings } from "../../types"
export interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
interface UseAiTitleGeneratorOptions {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
}
/**
* AI 标题生成 Hook
* 封装 AI 标题生成、刷新、选择等逻辑
*/
export function useAiTitleGenerator({
titleSettings,
onTitleSettingsChange,
}: UseAiTitleGeneratorOptions) {
const [aiTitleInput, setAiTitleInput] = useState("")
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
const extractTopic = (text: string): string => {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
return keywords.slice(0, 3).join("")
}
const generateTitlesFromTopic = (topic: string): AiTitleItem[] => {
const results: AiTitleItem[] = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
results.push({
title: tpl.replace(/\{topic\}/g, topic),
highlight: highlights[style],
style,
})
})
})
results.sort(() => Math.random() - 0.5)
return results
}
const handleGenerateAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) {
message.warning("请先输入视频描述或关键词")
return
}
setAiTitleGenerating(true)
setHasGeneratedTitles(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
const topic = extractTopic(aiTitleInput)
setAiTitleResults(generateTitlesFromTopic(topic))
setAiTitleGenerating(false)
}, [aiTitleInput])
/**
* 自动生成标题(供 AI 自动选择开关使用)
* 如果已有生成结果,直接从中选一个;否则用默认关键词生成
*/
const autoGenerateTitle = useCallback((): string => {
if (aiTitleResults.length > 0) {
const picked = aiTitleResults[Math.floor(Math.random() * aiTitleResults.length)]
if (!picked) return ""
onTitleSettingsChange({ ...titleSettings, title: picked.title })
return picked.title
}
// 没有已有结果,用默认关键词生成
const results = generateTitlesFromTopic("短视频")
setAiTitleResults(results)
setHasGeneratedTitles(true)
const picked = results[Math.floor(Math.random() * results.length)]
if (!picked) return ""
onTitleSettingsChange({ ...titleSettings, title: picked.title })
return picked.title
}, [aiTitleResults, titleSettings, onTitleSettingsChange])
const handleSelectAiTitle = useCallback(
(title: string) => {
onTitleSettingsChange({ ...titleSettings, title })
message.success("已选用此标题")
},
[titleSettings, onTitleSettingsChange],
)
const handleRefreshAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) return
setAiTitleGenerating(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const topic = extractTopic(aiTitleInput)
setAiTitleResults(generateTitlesFromTopic(topic))
setAiTitleGenerating(false)
}, [aiTitleInput])
return {
aiTitleInput,
setAiTitleInput,
aiTitleGenerating,
aiTitleResults,
hasGeneratedTitles,
handleGenerateAiTitles,
handleSelectAiTitle,
handleRefreshAiTitles,
autoGenerateTitle,
}
}
@@ -19,7 +19,7 @@ import {
interface UseStep6CoverProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/** 当前选中的模板 ID */
/** 当前选中的视频模板 ID(封面模板由本地 state 管理) */
selectedTemplate?: string
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
@@ -30,7 +30,7 @@ interface UseStep6CoverProps {
export function useStep6Cover({
coverSettings,
onCoverSettingsChange,
selectedTemplate = "",
selectedTemplate: _selectedTemplate = "",
titleSettings,
generatedVideos,
}: UseStep6CoverProps) {
@@ -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 () => {
@@ -79,7 +83,8 @@ export function useStep6Cover({
return
}
if (!selectedTemplate) {
const activeCoverTemplateId = selectedTemplateId || "default"
if (!activeCoverTemplateId) {
message.error("请先选择模板")
return
}
@@ -95,7 +100,7 @@ export function useStep6Cover({
}, 300000)
try {
const response = await generateCover(selectedTemplate, {
const response = await generateCover(activeCoverTemplateId, {
generated_video_id: finalVideo.id,
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
@@ -156,7 +161,7 @@ export function useStep6Cover({
setGenerating(false)
}
}, [
selectedTemplate,
selectedTemplateId,
finalVideo,
coverSettings,
onCoverSettingsChange,
@@ -1,165 +0,0 @@
/**
* 批量变体配音预览音频解析(#1750)
*
* 独立配音模式下每个变体挂载各自的配音 URL:
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
*
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
*/
import { useEffect, useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
import { previewTts } from "@/api/tts"
interface UseVariantVoicePreviewOptions {
/** 是否批量模式(count>1 */
enabled: boolean
/** 变体数量 */
count: number
/** 是否每个视频独立配音 */
perVideo: boolean
/** 共用配音 ID(独立模式下为变体 0 的配音) */
sharedVoiceId: string
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice */
clonedVoiceId?: string
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
variantVoiceIds: string[]
/** 各变体标题(TTS 合成文案源) */
titles: string[]
}
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
if (!id) return null
const m = materials.find((x) => x.id === id)
return m?.file_url || null
}
export function useVariantVoicePreview({
enabled,
count,
perVideo,
sharedVoiceId,
clonedVoiceId = "",
variantVoiceIds,
titles,
}: UseVariantVoicePreviewOptions): (string | null)[] {
const [urls, setUrls] = useState<(string | null)[]>([])
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
const { data: voiceMaterialsData } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
const ttsCacheRef = useRef<Map<string, string>>(new Map())
// 在途请求 AbortController
const abortRef = useRef<AbortController | null>(null)
const seqRef = useRef(0)
useEffect(() => {
if (!enabled || count <= 1) {
setUrls((prev) => (prev.length === 0 ? prev : []))
return
}
const seq = ++seqRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
// 每个变体实际使用的配音 ID
const voiceIds = Array.from({ length: count }, (_, i) =>
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
)
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
const result: (string | null)[] = voiceIds.map((id, i) => {
const materialUrl = findMaterialUrl(id, voiceMaterials)
if (materialUrl) return materialUrl
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
if (!perVideo && i === 0 && clonedVoiceId) {
return findMaterialUrl(clonedVoiceId, voiceMaterials)
}
return null
})
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
voiceIds.forEach((id, i) => {
if (result[i]) return
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
const title = titles[i] || ""
if (!ttsVoiceId || !title) return
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
})
setUrls((prev) =>
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
)
if (ttsJobs.length === 0) return
let cancelled = false
void (async () => {
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
for (const job of ttsJobs) {
const cacheKey = `${job.voiceId}|${job.title}`
const cached = ttsCacheRef.current.get(cacheKey)
if (cached) {
if (seq === seqRef.current) {
setUrls((prev) => {
if (prev[job.index] === cached) return prev
const next = [...prev]
next[job.index] = cached
return next
})
}
continue
}
try {
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
const audioUrl = res.audio_url || ""
if (audioUrl) {
ttsCacheRef.current.set(cacheKey, audioUrl)
setUrls((prev) => {
if (prev[job.index] === audioUrl) return prev
const next = [...prev]
next[job.index] = audioUrl
return next
})
}
} catch (err) {
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
}
}
})()
return () => {
cancelled = true
controller.abort()
}
}, [
enabled,
count,
perVideo,
sharedVoiceId,
clonedVoiceId,
variantVoiceIds,
titles,
voiceMaterials,
])
return urls
}
export default useVariantVoicePreview
-8
View File
@@ -81,14 +81,6 @@ export interface SmartMatchResult {
reasons: string[]
}
/* ── AI 标题结果 ── */
export interface AiTitleResult {
title: string
style: string
styleLabel: string
highlights: string[]
}
/* ── 配音推荐结果 ── */
export interface VoiceRecommendation {
voiceId: string
+233 -8
View File
@@ -32,6 +32,238 @@ 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
// 背景设置
backgroundEnabled: boolean
backgroundSize: number
backgroundPosition: TextPosition
// 主标题
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: true,
portraitSize: 80,
portraitPosition: { x: 50, y: 50 },
backgroundEnabled: true,
backgroundSize: 90,
backgroundPosition: { x: 50, y: 50 },
title: DEFAULT_TITLE_CONFIG,
subtitle: DEFAULT_SUBTITLE_CONFIG,
maskEnabled: true,
maskImage: "",
maskSize: 100,
maskPosition: { x: 50, y: 50 },
maskColor: "#000000",
maskOpacity: 100,
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 +271,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
}
@@ -1,176 +0,0 @@
/**
* CanvasPreviewGrid 单测(Issue #1741 / #1750
*
* 验证:
* - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中)
* - 每个变体都拿到各自的后端真实片段 variantClips[i]serverClips),playToken 为 0 基变体序号
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
* - 实例上报暂停(null)→ 播放权释放
* - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器),
* 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips
* - #1750 加载态:渲染加载占位,不渲染播放器
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, fireEvent, screen } from "@testing-library/react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
default: (props: Record<string, unknown>) => {
playerCalls.push(props)
const token = props.playToken as number
const active = props.activePlayToken as number | null
const change = props.onPlayTokenChange as (t: number | null) => void
return (
<div data-testid={`player-${token}`}>
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
<button type="button" onClick={() => change(token)}>
play-{token}
</button>
<button type="button" onClick={() => change(null)}>
pause-{token}
</button>
</div>
)
},
}))
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10 },
duration: 10,
}
}
function makeClips(variant: number): EditPlanClip[] {
return ["a1", "a2"].map((assetId, order) => ({
id: `clip-v${variant}-${assetId}`,
plan_id: `plan-${variant}`,
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}))
}
const titleSettings = {
size: 36,
font: "思源黑体",
color: "#fff",
position: "bottom" as const,
bold: false,
italic: false,
stroke: true,
shadow: false,
posX: null,
posY: null,
}
const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)]
function renderGrid(overrides: Record<string, unknown> = {}) {
playerCalls.length = 0
return render(
<CanvasPreviewGrid
count={3}
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
videoRatio="9:16"
titles={["标题1", "标题2", "标题3"]}
titleSettings={titleSettings}
variantClips={variantClips}
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
selectedIds={[0, 1, 2]}
onToggleSelect={() => {}}
{...overrides}
/>,
)
}
describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => {
beforeEach(() => {
playerCalls.length = 0
})
it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => {
renderGrid()
expect(playerCalls).toHaveLength(3)
playerCalls.forEach((p, i) => {
expect(p.playToken).toBe(i)
expect(p.serverClips).toBe(variantClips[i])
expect(p.variantTitle).toBe(`标题${i + 1}`)
})
// 独立配音:变体 0/2 有各自 URL;变体 1 为 nullTTS 合成中)→ 传 undefined
expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3")
expect(playerCalls[1].voiceAudioUrl).toBeUndefined()
expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3")
// 配音状态徽标
expect(screen.getByTestId("voice-0").textContent).toBe("has-voice")
expect(screen.getByTestId("voice-1").textContent).toBe("no-voice")
expect(screen.getByTestId("voice-2").textContent).toBe("has-voice")
})
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
renderGrid()
playerCalls.forEach((p) => {
expect(p.activePlayToken).toBeNull()
expect(typeof p.onPlayTokenChange).toBe("function")
})
})
it("点击变体2播放:所有实例 activePlayToken 变为 10 基,其他实例自动暂停)", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-1").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("1")
expect(screen.getByTestId("token-1").textContent).toBe("1")
expect(screen.getByTestId("token-2").textContent).toBe("1")
})
it("正在播放实例上报暂停后,播放权释放(token 回 null", () => {
renderGrid()
fireEvent.click(screen.getByTestId("player-2").querySelector("button")!)
expect(screen.getByTestId("token-0").textContent).toBe("2")
fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1])
expect(screen.getByTestId("token-2").textContent).toBe("none")
})
it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => {
const onRetry = vi.fn()
renderGrid({ clipsError: true, onRetryClips: onRetry })
expect(screen.getAllByRole("alert")).toHaveLength(3)
expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3)
// 严禁假数据:错误态不渲染播放器
expect(playerCalls).toHaveLength(0)
// 重试按钮仅变体 0 卡片出现
const retryButtons = screen.getAllByText("重试")
expect(retryButtons).toHaveLength(1)
fireEvent.click(retryButtons[0])
expect(onRetry).toHaveBeenCalledTimes(1)
})
it("clipsLoading:渲染加载占位,不渲染播放器", () => {
renderGrid({ clipsLoading: true })
expect(screen.getAllByText("独立选片中…")).toHaveLength(3)
expect(screen.queryAllByRole("alert")).toHaveLength(0)
expect(playerCalls).toHaveLength(0)
})
})
@@ -1,213 +0,0 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750
*
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
* - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause
* - 点击播放/暂停时上报播放权(onPlayTokenChange
* - #1750serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
const mocks = vi.hoisted(() => ({
isPlaying: false,
pause: vi.fn(),
togglePlayPause: vi.fn(),
}))
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
// segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips);
// canPlay 与真实 hook 一致:无片段时不可播放
useSegmentScheduler: vi.fn((segments: unknown[]) => ({
isPlaying: mocks.isPlaying,
currentTime: 0,
totalDuration: 20,
currentSegmentIndex: 0,
canPlay: segments.length > 0,
togglePlayPause: mocks.togglePlayPause,
seekTo: vi.fn(),
pause: mocks.pause,
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
})),
}))
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
useCanvasPlayer: () => ({
state: {
isPlaying: false,
isReady: false,
isBuffering: false,
currentTime: 0,
duration: 0,
errorMessage: "",
hasDecodeError: false,
},
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
}),
}))
beforeEach(() => {
mocks.isPlaying = false
mocks.pause.mockClear()
mocks.togglePlayPause.mockClear()
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
function makeAsset(id: string): AssetItem {
return {
id,
library_id: "lib-1",
name: `${id}.mp4`,
storage_key: `media/${id}.mp4`,
file_url: `https://cdn.example.com/${id}.mp4`,
mime_type: "video/mp4",
metadata: { duration: 10, width: 1080, height: 1920 },
duration: 10,
}
}
function makeClip(assetId: string, order: number): EditPlanClip {
return {
id: `clip-${assetId}`,
plan_id: "plan-1",
clip_type: "main",
order,
asset_id: assetId,
text_content: "",
start_time: 0,
duration: 5,
transition_effect: "none",
transition_duration: 0,
playback_speed: 1,
status: "ready",
config: {},
}
}
const serverClips = [makeClip("a1", 0), makeClip("a2", 1)]
const baseProps = {
assets: [makeAsset("a1"), makeAsset("a2")],
videoRatio: "9:16",
ready: true,
serverClips,
}
function videos(): HTMLVideoElement[] {
return Array.from(document.querySelectorAll("video"))
}
describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => {
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
expect(videos()).toHaveLength(2)
videos().forEach((v) => expect(v.muted).toBe(true))
})
it("无配音时 video 不 muted(素材原声兜底)", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
render(<FrontendPreviewPlayer {...baseProps} />)
const vs = videos()
expect(vs[0].muted).toBe(false)
fireEvent.click(screen.getByRole("button", { name: "静音" }))
videos().forEach((v) => expect(v.muted).toBe(true))
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
videos().forEach((v) => expect(v.muted).toBe(false))
})
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
mocks.isPlaying = true
const { rerender } = render(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).not.toHaveBeenCalled()
// 播放权切给实例 2(0 基 token)
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={2}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
// token 切回自己:不重复暂停
rerender(
<FrontendPreviewPlayer
{...baseProps}
playToken={1}
activePlayToken={1}
onPlayTokenChange={() => {}}
compact
/>,
)
expect(mocks.pause).toHaveBeenCalledTimes(1)
})
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
// mocks.isPlaying = falsebeforeEach 重置)
const { rerender } = render(
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
)
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
expect(mocks.pause).not.toHaveBeenCalled()
})
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
const onToken = vi.fn()
render(
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
)
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
expect(playButtons.length).toBeGreaterThanOrEqual(1)
fireEvent.click(playButtons[0])
expect(onToken).toHaveBeenCalledWith(2)
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
})
it("每个卡片都渲染独立静音按钮", () => {
render(
<div>
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
</div>,
)
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
})
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
expect(videos()).toHaveLength(0)
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
})
})
@@ -19,7 +19,6 @@ import "@/pages/generate/components/Step2MaterialSelect"
import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/Step3VoiceWithMode"
import "@/pages/generate/components/CanvasPreviewGrid"
import "@/pages/generate/components/BatchGenerationGrid"
import "@/pages/generate/components/PreviewCountModal"
import "@/pages/generate/components/GenerateStepContent"
@@ -33,8 +32,6 @@ import "@/pages/generate/components/material/MaterialModeTabs"
import "@/pages/generate/components/material/ManualMaterialList"
import "@/pages/generate/components/material/SmartMatchInput"
import "@/pages/generate/components/material/SmartMatchResults"
import "@/pages/generate/components/title/AiTitleGenerator"
import "@/pages/generate/components/title/AiTitleCard"
import "@/pages/generate/components/title/TitleStylePanel"
import "@/pages/generate/components/title/TitlePresetsGrid"
import "@/pages/generate/utils/formatDuration"
@@ -48,9 +45,6 @@ describe("GeneratePage module smoke test", () => {
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/useBatchCovers"
import "@/pages/generate/hooks/useBatchVariantPlans"
import "@/pages/generate/hooks/useVariantVoicePreview"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
@@ -1,18 +0,0 @@
/**
* Step4+5 merged preview smoke test
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/components/FrontendPreviewPlayer"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Merged Step4+5 preview module smoke test", () => {
it("should load all merged step4+5 preview modules", () => {
expect(true).toBe(true)
})
})
+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,
}
@@ -16,6 +16,7 @@ from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_scale_len,
_wrap_title_text,
build_ass_style,
escape_ass_text,
@@ -133,22 +134,23 @@ def generate_ass_from_timeline(
output_path.write_text("", encoding="utf-8")
return output_path
# 样式参数
# 样式参数(字号/描边/边距按视频宽度缩放,基准 720p,与前端预览一致)
_sw = video_width
font_name = subtitle_config.get("font", "思源黑体")
font_size = int(subtitle_config.get("size", 24))
font_size = _scale_len(int(subtitle_config.get("size", 24)), _sw)
color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
position = subtitle_config.get("position", "bottom")
alignment = _position_to_ass_alignment(position)
max_chars_per_line = int(subtitle_config.get("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE))
# 描边(默认黑色描边,保证可读性)
# 描边(默认黑色描边,保证可读性)—— 720p 基准 1.5
outline_color = "&H00000000"
outline_width = 1.5
outline_width = _scale_len(1.5, _sw)
# 边距
margin_v = 60 if position == "bottom" else 60
margin_l = 40
margin_r = 40
# 边距720p 基准 60/40
margin_v = _scale_len(60 if position == "bottom" else 60, _sw)
margin_l = _scale_len(40, _sw)
margin_r = _scale_len(40, _sw)
# 生成样式行
style_line = (
@@ -203,22 +205,29 @@ def generate_ass_from_timeline(
if "font_color" in title_cfg and "color" not in title_cfg:
title_cfg["color"] = title_cfg["font_color"]
_tsw = video_width
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
t_stroke = title_cfg.get("stroke", {}) or {}
t_shadow = title_cfg.get("shadow", {}) or {}
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
s_width = _scale_len(float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0, _tsw)
sh_blur = _scale_len(float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0, _tsw)
sh_offset = (
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
_scale_len(t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0, _tsw),
_scale_len(t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0, _tsw),
)
t_alignment = position_to_ass_alignment(title_cfg.get("position", "bottom"))
# 标题字号/边距按视频宽度缩放(基准 720p),移除旧的 min(...,36) 上限避免 1080p 被钳位过小
_base_title_size = int(title_cfg.get("size", 36))
t_font_size = _scale_len(_base_title_size, _tsw)
_t_margin_top = _scale_len(TITLE_MARGIN_TOP, _tsw)
_t_margin_side = _scale_len(TITLE_MARGIN_SIDE, _tsw)
title_style_line = build_ass_style(
"TitleStyle",
font_name=title_cfg.get("font", "思源黑体"),
font_size=min(int(title_cfg.get("size", 36)), 36),
font_size=t_font_size,
primary_color=t_color,
outline_color=s_color,
outline_width=s_width,
@@ -227,14 +236,15 @@ def generate_ass_from_timeline(
bold=bool(title_cfg.get("bold", True)),
italic=bool(title_cfg.get("italic", False)),
alignment=t_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
margin_v=_t_margin_top,
margin_l=_t_margin_side,
margin_r=_t_margin_side,
)
t_font_size = min(int(title_cfg.get("size", 36)), 36)
safe_raw = escape_ass_text(title_text.strip())
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
safe_wrapped = _wrap_title_text(
safe_raw, video_width, t_font_size, margin_l=_t_margin_side, margin_r=_t_margin_side
)
if video_duration > 0:
t_end_time = format_ass_time(video_duration)
+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,
}
+82 -12
View File
@@ -59,6 +59,31 @@ FONT_NAME_MAP: dict[str, str] = {
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
ASS_FONTSIZE_COMPENSATION = 1.35
# 前端预览字号的基准宽度(px)。前端 TitleMiniPreview 以 width/360 缩放、titleCanvas.ts
# 以 videoWidth/720 缩放(360 是 720 的一半,字号等比例一致),types.ts 注释 "px @720p"。
# 当输出视频宽度 ≠ 720 时,所有长度类字段(字号/描边宽/阴影偏移/边距)
# 都按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片标题视觉大小与预览一致。
TITLE_SIZE_REF_WIDTH = 720
def _scale_len(value, video_width: int):
"""将 720p 基准长度值按 video_width 等比缩放。
整数输入 → 返回 int(用于字号、描边宽、边距、偏移等整数字段);
浮点输入 → 返回 float(保留小数,用于 1.5 这类 outline 细描边);
非数值原样返回。
"""
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return value
if not video_width or video_width <= 0:
return int(round(v)) if isinstance(value, int) else v
scaled = v * (video_width / TITLE_SIZE_REF_WIDTH)
return int(round(scaled)) if isinstance(value, int) else scaled
def _compensate_ass_fontsize(font_size: int) -> int:
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
@@ -374,6 +399,44 @@ def build_ass_content(
else {"enabled": False}
)
# ── 字号/描边/阴影/边距按视频宽度缩放(基准 720p,与前端预览一致) ──
# 前端标题/字幕面板的 size 语义是 "px @720p"(见 types.ts 注释、titleCanvas.ts scale=videoWidth/720),
# 当输出分辨率不是 720p(如 1080x1920 竖屏、4K)时等比放大,避免成片标题比预览小。
def _scale_cfg(cfg: dict, defaults: dict | None = None) -> None:
if not isinstance(cfg, dict):
return
# 先填充默认值,再统一缩放,避免 .get("size", 36) 处拿未缩放默认值
if defaults:
for k, v in defaults.items():
cfg.setdefault(k, v)
sw = video_width
for key in ("size", "font_size"):
if key in cfg:
try:
cfg[key] = _scale_len(cfg[key], sw)
except (TypeError, ValueError):
pass
# stroke/shadow 的 width/blur/offset 由后续字段提取处统一按 720p 默认值+缩放处理
# (包含 enabled=false 时不写入、默认值 2/4 也需缩放),此处不再重复缩放
for key in ("margin_top", "bg_padding", "bg_radius", "pos_x", "pos_y"):
# pos_x/pos_y 是百分比(0-100)不缩放,margin/padding/radius 是 px @720p 需要缩放
if key in cfg and key not in ("pos_x", "pos_y"):
cfg[key] = _scale_len(cfg.get(key), sw)
# 逐行覆盖的 size 也要缩放
overrides = cfg.get("line_overrides")
if isinstance(overrides, list):
for ov in overrides:
if isinstance(ov, dict) and "size" in ov:
ov["size"] = _scale_len(ov.get("size"), sw)
_scale_cfg(title_config, defaults={"size": 36})
_scale_cfg(subtitle_config, defaults={"size": 24})
# 边距按视频宽度缩放(基准 720p)
_margin_top = _scale_len(TITLE_MARGIN_TOP, video_width) or TITLE_MARGIN_TOP
_margin_bottom = _scale_len(TITLE_MARGIN_BOTTOM, video_width) or TITLE_MARGIN_BOTTOM
_margin_side = _scale_len(TITLE_MARGIN_SIDE, video_width) or TITLE_MARGIN_SIDE
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
@@ -389,11 +452,16 @@ def build_ass_content(
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
# 描边/阴影默认值也是 720p 基准,需要随视频宽度缩放
_def_stroke_w = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
_def_shadow_b = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
_def_shadow_x = title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0
_def_shadow_y = title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0
stroke_width = float(_scale_len(_def_stroke_w, video_width))
shadow_blur = float(_scale_len(_def_shadow_b, video_width))
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
_scale_len(_def_shadow_x, video_width),
_scale_len(_def_shadow_y, video_width),
)
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
@@ -420,9 +488,9 @@ def build_ass_content(
bold=bool(title_config.get("bold", True)),
italic=bool(title_config.get("italic", False)),
alignment=title_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
margin_v=_margin_top,
margin_l=_margin_side,
margin_r=_margin_side,
)
)
@@ -430,7 +498,9 @@ def build_ass_content(
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
title_font_size = int(title_config.get("size", 36))
safe_title_text_raw = escape_ass_text(title_text)
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
safe_title_text = _wrap_title_text(
safe_title_text_raw, video_width, title_font_size, margin_l=_margin_side, margin_r=_margin_side
)
# #2001 逐行样式覆盖:按 line_overrides 在每行前注入 ASS inline override 标签
# line_overrides 透传自前端爆款标题面板,SubtitleStyle.from_dict 已做安全过滤
@@ -460,15 +530,15 @@ def build_ass_content(
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
outline_width=float(_scale_len(1.0, video_width)),
shadow_blur=0.0,
shadow_offset=(0, 0),
bold=False,
italic=False,
alignment=sub_alignment,
margin_v=TITLE_MARGIN_BOTTOM,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
margin_v=_margin_bottom,
margin_l=_margin_side,
margin_r=_margin_side,
)
)
+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 置 100completed_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
+32 -10
View File
@@ -428,6 +428,26 @@ DRAWTEXT_FONT_MAP: dict[str, str] = {
}
# 前端标题/字幕面板的字号/描边/阴影/边距语义是 "px @720p"(见 web/src/pages/generate/types.ts
# 注释、apps/web/src/pages/ai-avatar/utils/titleCanvas.ts scale = videoWidth / 720)。
# 当输出视频宽度 != 720 时(如 1080x1920 竖屏、4K),drawtext 降级路径需要按比例
# 缩放所有长度类字段,避免成片标题视觉上比前端预览小。
TITLE_DRAWTEXT_REF_WIDTH = 720
def _scale_title_len(value, output_width: int):
"""将 720p 基准长度值按 output_width 等比缩放,返回 int."""
if value is None:
return 0
try:
v = float(value)
except (TypeError, ValueError):
return 0
if not output_width or output_width <= 0:
return int(round(v))
return int(round(v * (output_width / TITLE_DRAWTEXT_REF_WIDTH)))
def _escape_drawtext_text(text: str) -> str:
"""转义 drawtext 特殊字符。
@@ -514,6 +534,8 @@ def build_title_drawtext_filter(
# ── 样式参数 ──
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
font_size = int(title_config.get("font_size") or title_config.get("size") or 48)
# 字号/描边/阴影按视频宽度等比缩放(基准 720p,与前端预览一致)
font_size = _scale_title_len(font_size, output_width)
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
if font_color.startswith("#"):
@@ -549,31 +571,31 @@ def build_title_drawtext_filter(
border_color = "000000"
if stroke:
if isinstance(stroke, bool):
border_width = 2
border_width = _scale_title_len(2, output_width)
border_color = "000000"
elif isinstance(stroke, dict):
if stroke.get("enabled", True):
border_width = int(stroke.get("width", 2))
border_width = _scale_title_len(int(stroke.get("width", 2)), output_width)
border_color = (stroke.get("color") or "#000000").lstrip("#")
elif bold:
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影
border_width = 2
border_width = _scale_title_len(2, output_width)
border_color = "000000"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
# 阴影(shadowcolor + shadowx/y
# 阴影(shadowcolor + shadowx/y—— 同样按视频宽度缩放
if shadow:
if isinstance(shadow, bool):
params.append("shadowcolor=black")
params.append("shadowx=2")
params.append("shadowy=2")
params.append(f"shadowx={_scale_title_len(2, output_width)}")
params.append(f"shadowy={_scale_title_len(2, output_width)}")
elif isinstance(shadow, dict):
if shadow.get("enabled", True):
params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}")
params.append(f"shadowx={int(shadow.get('offset_x', 2))}")
params.append(f"shadowy={int(shadow.get('offset_y', 2))}")
params.append(f"shadowx={_scale_title_len(int(shadow.get('offset_x', 2)), output_width)}")
params.append(f"shadowy={_scale_title_len(int(shadow.get('offset_y', 2)), output_width)}")
# ── 位置计算 ──
# 优先使用自定义坐标 pos_x / pos_y
@@ -600,10 +622,10 @@ def build_title_drawtext_filter(
if position == "center":
params.append("y=(h-text_h)/2")
elif position == "bottom":
params.append("y=h-text_h-50")
params.append(f"y=h-text_h-{_scale_title_len(50, output_width)}")
else:
# top(默认)
params.append("y=50")
params.append(f"y={_scale_title_len(50, output_width)}")
return "drawtext=" + ":".join(params)
+49 -49
View File
@@ -285,20 +285,20 @@ class TestFormatAssTime:
class TestBuildAssContent:
def test_no_subtitles_returns_empty(self):
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
result = build_ass_content(video_width=720, video_height=1280, video_duration=10.0)
assert result == ""
def test_title_only(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Test Title",
)
assert result != ""
assert "[Script Info]" in result
assert "PlayResX: 1920" in result
assert "PlayResY: 1080" in result
assert "PlayResX: 720" in result
assert "PlayResY: 1280" in result
assert "[V4+ Styles]" in result
assert "[Events]" in result
assert "TitleStyle" in result
@@ -306,8 +306,8 @@ class TestBuildAssContent:
def test_subtitle_only(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="Hello Subtitle",
)
@@ -317,8 +317,8 @@ class TestBuildAssContent:
def test_both_title_and_subtitle(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Title",
subtitle_text="Subtitle",
@@ -330,8 +330,8 @@ class TestBuildAssContent:
def test_whitespace_title_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text=" ",
)
@@ -339,8 +339,8 @@ class TestBuildAssContent:
def test_whitespace_subtitle_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text=" \n ",
)
@@ -348,8 +348,8 @@ class TestBuildAssContent:
def test_title_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Title",
title_config={"enabled": False},
@@ -358,8 +358,8 @@ class TestBuildAssContent:
def test_subtitle_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="Sub",
subtitle_config={"enabled": False},
@@ -368,8 +368,8 @@ class TestBuildAssContent:
def test_title_color(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"color": "#FF0000"},
@@ -379,8 +379,8 @@ class TestBuildAssContent:
def test_title_position_top(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"position": "top"},
@@ -390,8 +390,8 @@ class TestBuildAssContent:
def test_title_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"position": "bottom"},
@@ -401,8 +401,8 @@ class TestBuildAssContent:
def test_subtitle_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="S",
subtitle_config={"position": "bottom"},
@@ -411,8 +411,8 @@ class TestBuildAssContent:
def test_title_font_size(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"size": 72},
@@ -427,8 +427,8 @@ class TestBuildAssContent:
def test_title_font_size_frontend_field_alias(self):
"""前端传 font_size 应归一化为内部 size 字段。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"font_size": 48},
@@ -442,8 +442,8 @@ class TestBuildAssContent:
def test_title_font_color_frontend_field_alias(self):
"""前端传 font_color 应归一化为内部 color 字段。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"font_color": "#FF0000"},
@@ -454,8 +454,8 @@ class TestBuildAssContent:
def test_title_size_takes_precedence_over_font_size(self):
"""同时传 size 和 font_size 时,size 优先。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"size": 56, "font_size": 28},
@@ -468,8 +468,8 @@ class TestBuildAssContent:
def test_title_bold(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"bold": True},
@@ -482,8 +482,8 @@ class TestBuildAssContent:
def test_title_stroke_enabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
@@ -496,8 +496,8 @@ class TestBuildAssContent:
def test_title_stroke_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": False, "width": 3}},
@@ -510,8 +510,8 @@ class TestBuildAssContent:
def test_title_shadow_enabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}},
@@ -524,8 +524,8 @@ class TestBuildAssContent:
def test_dialogue_has_correct_timing(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=65.5,
title_text="T",
)
@@ -534,8 +534,8 @@ class TestBuildAssContent:
def test_dialogue_starts_at_zero(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=10.0,
subtitle_text="S",
)
@@ -543,8 +543,8 @@ class TestBuildAssContent:
def test_contains_script_info_header(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=10.0,
title_text="T",
)
@@ -554,8 +554,8 @@ class TestBuildAssContent:
def test_escaped_text_in_dialogue(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=10.0,
title_text="line1\nline2",
)
+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"
+112 -2
View File
@@ -587,9 +587,10 @@ class TestFontsizeCompensation:
assert parts[2] == "120"
def test_subtitle_also_compensated(self):
# 720p 基准宽度:只做 ASS fontsize 1.35x 补偿,不做分辨率缩放
content = build_ass_content(
video_width=1080,
video_height=1920,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="字幕",
subtitle_config={"size": 24},
@@ -789,3 +790,112 @@ class TestDefaultPositionBottom:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
assert fields[18] == "8"
class TestTitleFontsizeScaling:
"""标题/字幕字号按 video_width/720 等比缩放(工单:标题字号校准)。
前端标题面板 size 语义是"px @720p",输出到 1080p/4K 等非 720 宽度
视频时需要等比放大所有长度类字段(字号/描边/阴影/边距),保证成片
标题视觉大小与前端预览一致。
"""
BASE_KWARGS = dict(video_duration=5.0, title_text="标题测试", subtitle_text="字幕测试")
def _title_fontsize(self, content: str) -> int:
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
return int(line.split(",")[2])
def _subtitle_fontsize(self, content: str) -> int:
line = [ln for ln in content.splitlines() if ln.startswith("Style: SubtitleStyle")][0]
return int(line.split(",")[2])
def test_720p_title_no_scale(self):
"""720p 宽度时只做 1.35x ASS 补偿,不做分辨率缩放。"""
from packages.domain.ass_subtitle_builder import ASS_FONTSIZE_COMPENSATION
content = build_ass_content(
video_width=720, video_height=1280, **self.BASE_KWARGS, title_config={"size": 28}
)
expected = round(28 * ASS_FONTSIZE_COMPENSATION) # 38
assert self._title_fontsize(content) == expected
def test_1080p_title_scaled(self):
"""1080 宽度(1080x1920 竖屏)时 size=28 → 28*1.5=42 → ASS 补偿 → round(42*1.35)=57."""
content = build_ass_content(
video_width=1080, video_height=1920, **self.BASE_KWARGS, title_config={"size": 28}
)
# 28 * 1080/720 = 42; round(42*1.35)=57
assert self._title_fontsize(content) == 57
def test_1920p_title_scaled(self):
"""1920 宽度(横屏 1920x1080)时 size=28 → 28*1920/720≈75 → round(75*1.35)=101."""
content = build_ass_content(
video_width=1920, video_height=1080, **self.BASE_KWARGS, title_config={"size": 28}
)
assert self._title_fontsize(content) == 101
def test_subtitle_scaled_1080p(self):
"""字幕默认 size=24 在 1080p 下也应按比例缩放:24*1.5=36 → round(36*1.35)=49."""
content = build_ass_content(
video_width=1080, video_height=1920, **self.BASE_KWARGS, subtitle_config={"size": 24}
)
assert self._subtitle_fontsize(content) == 49
def test_stroke_width_scaled(self):
"""描边宽度(Style 字段 Outline=16 索引)按同比例缩放。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
**self.BASE_KWARGS,
title_config={"size": 36, "stroke": {"enabled": True, "width": 4, "color": "#000000"}},
)
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in line.split(",")]
# Outline width 字段索引 16
outline = float(fields[16])
# 4 * 1.5 = 6
assert outline == 6.0
def test_shadow_offset_scaled(self):
"""阴影偏移 x/y(Shadow 字段 17 存单值;验证 style line 中无异常)。
shadow 同时注入 \\4a/\\xshad/\\yshad 级标签,这里检查 style line。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
**self.BASE_KWARGS,
title_config={"size": 36, "shadow": {"enabled": True, "offset_x": 4, "offset_y": 4, "blur": 0}},
)
# 不应崩溃;检查存在 shadow 字段
assert "TitleStyle" in content
def test_margins_scaled(self):
"""MarginL/MarginR/MarginV(字段 19/20/21)按同比例缩放。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
**self.BASE_KWARGS,
title_config={"size": 36, "position": "top"},
)
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in line.split(",")]
# 默认 TITLE_MARGIN_TOP=180, TITLE_MARGIN_SIDE=40, 1080/720=1.5
assert int(fields[19]) == 60 # margin_l = 40*1.5 = 60
assert int(fields[20]) == 60 # margin_r = 60
assert int(fields[21]) == 270 # margin_v = 180*1.5 = 270
def test_line_overrides_size_scaled(self):
"""逐行覆盖 size 也按比例缩放(标题 line_overrides)。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
video_duration=5.0,
title_text="第一行\\N第二行",
title_config={
"size": 36,
"line_overrides": [{"line_index": 0, "size": 72}],
},
)
# 行 0 应注入 \\fs 标签:72 * 1.5 = 108,然后 ASS 补偿仅作用于 style fontsizeinline fs 已是 PlayRes 像素
assert "\\fs108" in content
+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()
+6 -6
View File
@@ -143,7 +143,7 @@ class TestGenerateAssFromTimeline:
generate_ass_from_timeline(
output_path,
timeline,
video_width=1280,
video_width=720,
video_height=720,
subtitle_config={
"font": "微软雅黑",
@@ -684,8 +684,8 @@ class TestGenerateAssFromTimelineMore:
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
subtitle_config={"size": 48},
)
content = output.read_text(encoding="utf-8")
@@ -706,10 +706,10 @@ class TestGenerateAssFromTimelineMore:
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "720p.ass"
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
generate_ass_from_timeline(output, timeline, video_width=720, video_height=1280)
content = output.read_text(encoding="utf-8")
assert "PlayResX: 1280" in content
assert "PlayResY: 720" in content
assert "PlayResX: 720" in content
assert "PlayResY: 1280" in content
def test_special_characters_in_text(self):
"""字幕文本含特殊字符(花括号、换行)"""
+15 -15
View File
@@ -31,7 +31,7 @@ class TestFontSize:
title_text="测试标题",
title_config=config,
)
assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}"
assert ",73," in content, f"默认字号36应缩放+补偿为73(36*1.5*1.35),实际内容: {content}"
def test_size_32_preserved(self):
"""size=32 应原样使用。"""
@@ -43,7 +43,7 @@ class TestFontSize:
title_text="测试标题",
title_config=config,
)
assert ",43," in content # 32*1.35=43
assert ",65," in content # 32*1.5*1.35=65
def test_size_60_preserved(self):
"""size=60 应原样保留(字号上限已移除)。"""
@@ -58,7 +58,7 @@ class TestFontSize:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
font_size = int(fields[2])
assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}"
assert font_size == 122, f"字号60应缩放+补偿为122(60*1.5*1.35), 实际={font_size}"
def test_font_size_alias_normalized(self):
"""前端传 font_size 应归一化为 size。"""
@@ -72,7 +72,7 @@ class TestFontSize:
)
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}"
assert fields[2] == "105", f"font_size=52 应缩放+补偿为105(52*1.5*1.35), 实际={fields[2]}"
def test_font_color_alias_normalized(self):
"""前端传 font_color 应归一化为 color。"""
@@ -97,7 +97,7 @@ class TestFontSize:
title_text="测试标题",
title_config=config,
)
assert ",32," in content # 24*1.35=32.4→32
assert ",49," in content # 24*1.5*1.35=49.4→32
class TestBooleanStrokeNormalization:
@@ -117,7 +117,7 @@ class TestBooleanStrokeNormalization:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
outline_width = float(fields[16])
assert outline_width == 2.0, f"stroke=true 应产生 outline_width=2, 实际={outline_width}"
assert outline_width == 3.0, f"stroke=true 应产生 outline_width=3(2*1.5 scale), 实际={outline_width}"
def test_stroke_false_no_outline(self):
"""stroke=false 应生成 outline_width=0 的样式。"""
@@ -147,7 +147,7 @@ class TestBooleanStrokeNormalization:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
outline_width = float(fields[16])
assert outline_width == 3.0, f"自定义stroke width=3 应保留, 实际={outline_width}"
assert outline_width == 4.5, f"自定义stroke width=3 @1080p 应缩放为4.5(3*1.5), 实际={outline_width}"
class TestBooleanShadowNormalization:
@@ -167,7 +167,7 @@ class TestBooleanShadowNormalization:
fields = [f.strip() for f in style_line.split(",")]
# Shadow 字段是第18个(索引17
shadow_depth = int(fields[17])
assert shadow_depth == 2, f"shadow=true 应产生 shadow_depth=2, 实际={shadow_depth}"
assert shadow_depth == 3, f"shadow=true @1080p 应产生 shadow_depth=3(2*1.5), 实际={shadow_depth}"
def test_shadow_false_no_shadow(self):
"""shadow=false 应生成 shadow_depth=0 的样式。"""
@@ -197,7 +197,7 @@ class TestBooleanShadowNormalization:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
shadow_depth = int(fields[17])
assert shadow_depth == 3, f"自定义shadow offset_y=3 应保留, 实际={shadow_depth}"
assert shadow_depth == 4, f"自定义shadow offset_y=3 @1080p 应缩放为4(3*1.5), 实际={shadow_depth}"
class TestFullStyleConsistency:
@@ -231,14 +231,14 @@ class TestFullStyleConsistency:
# Fontname
assert fields[1] == "Noto Sans SC"
# Fontsize = 28*1.35=37.8→38
assert fields[2] == "38"
# Fontsize = 28*1.5*1.35=56.7→57
assert fields[2] == "57" # 28*1.5*1.35=57
# Bold = -1 (True)
assert fields[7] == "-1"
# Outline width = 2 (前端默认 stroke width)
assert float(fields[16]) == 2.0
# Shadow depth = 2 (offset_y)
assert int(fields[17]) == 2
# Outline width = 2*1.5=3.0 @1080p (前端默认 stroke width 经缩放)
assert float(fields[16]) == 3.0
# Shadow depth = 2*1.5=3 @1080p (offset_y 经缩放)
assert int(fields[17]) == 3
# Alignment = 8 (top)
assert int(fields[18]) == 8
+101 -25
View File
@@ -944,7 +944,7 @@ class TestDrawtextFontFileIncluded(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_fontfile_in_output(self, mock_font):
mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontfile=", result)
self.assertIn("NotoSansCJK", result)
@@ -952,7 +952,7 @@ class TestDrawtextFontFileIncluded(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_fontfile_escaped(self, mock_font):
mock_font.return_value = "/path/with:special'chars.ttf"
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontfile=", result)
@@ -963,7 +963,7 @@ class TestDrawtextFontFileNotIncluded(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_no_fontfile(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("fontfile=", result)
@@ -972,14 +972,14 @@ class TestDrawtextStrokeBranches(unittest.TestCase):
"""stroke 各分支覆盖。"""
def test_stroke_non_bool_non_dict(self):
result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"})
result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("borderw", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_dict_default_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}})
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
self.assertIn("bordercolor=000000", result)
@@ -987,14 +987,14 @@ class TestDrawtextStrokeBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_dict_enabled_false(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}})
result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("borderw", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_dict_custom_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}})
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("bordercolor=ff0000", result)
@@ -1005,7 +1005,7 @@ class TestDrawtextShadowBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_shadow_dict_default_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}})
result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("shadowcolor=000000", result)
self.assertIn("shadowx=5", result)
@@ -1014,7 +1014,7 @@ class TestDrawtextShadowBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_shadow_dict_disabled(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}})
result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("shadowcolor", result)
@@ -1022,7 +1022,8 @@ class TestDrawtextShadowBranches(unittest.TestCase):
def test_shadow_dict_custom_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}}
{"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}},
output_width=720,
)
self.assertIsNotNone(result)
self.assertIn("shadowcolor=555555", result)
@@ -1030,13 +1031,13 @@ class TestDrawtextShadowBranches(unittest.TestCase):
class TestDrawtextBoldFalse(unittest.TestCase):
def test_bold_false(self):
result = build_title_drawtext_filter({"text": "标题", "bold": False})
result = build_title_drawtext_filter({"text": "标题", "bold": False}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
def test_bold_true_does_not_use_font_bold_param(self):
"""粗体模式不得使用 `font=bold`——该参数无效,会导致 filter_complex 解析失败(exit 234)。"""
result = build_title_drawtext_filter({"text": "标题", "bold": True})
result = build_title_drawtext_filter({"text": "标题", "bold": True}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
# 粗体应通过 borderw 实现
@@ -1047,7 +1048,7 @@ class TestDrawtextBoldFalse(unittest.TestCase):
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
不得使用与文字同色的 borderw>=3否则会造成竖屏小字号重影"""
mock_font.return_value = "" # 无粗体字体
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("borderw=2", result)
# 黑描边:要么是 black 关键字,要么是 000000
@@ -1059,7 +1060,8 @@ class TestDrawtextBoldFalse(unittest.TestCase):
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}},
output_width=720,
)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
@@ -1072,21 +1074,21 @@ class TestDrawtextPositionBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_top_explicit(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "top"})
result = build_title_drawtext_filter({"text": "标题", "position": "top"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("y=50", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_center_explicit(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "center"})
result = build_title_drawtext_filter({"text": "标题", "position": "center"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("y=(h-text_h)/2", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_bottom_explicit(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "bottom"})
result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("y=h-text_h-50", result)
@@ -1095,7 +1097,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
"""自定义位置:百分比坐标转换为 drawtext 表达式."""
mock_font.return_value = ""
# pos_x=50, pos_y=30 → x=(w-text_w)*0.5000, y=(h-text_h)*0.3000
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30})
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*0.5000", result)
self.assertIn("y=(h-text_h)*0.3000", result)
@@ -1104,7 +1106,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
def test_position_custom_clamped_to_100(self, mock_font):
"""自定义位置:超过100的坐标被截断到100%."""
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*1.0000", result)
self.assertIn("y=(h-text_h)*1.0000", result)
@@ -1112,7 +1114,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_bool_coords_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True})
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)/2", result)
self.assertIn("y=50", result)
@@ -1120,7 +1122,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
class TestDrawtextColorNoHash(unittest.TestCase):
def test_color_without_hash(self):
result = build_title_drawtext_filter({"text": "标题", "font_color": "red"})
result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontcolor=red", result)
@@ -1131,27 +1133,27 @@ class TestDrawtextFieldNormalization(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_content_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"content": "备用标题"})
result = build_title_drawtext_filter({"content": "备用标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("备用标题", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_font_preset_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"})
result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}, output_width=720)
self.assertIsNotNone(result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_size_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "size": 72})
result = build_title_drawtext_filter({"text": "标题", "size": 72}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontsize=72", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_color_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"})
result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontcolor=abcdef", result)
@@ -1256,5 +1258,79 @@ class TestTitleOverlay(unittest.TestCase):
)
class TestDrawtextFontsizeScaling(unittest.TestCase):
"""字号/描边/阴影/边距按 video_width/720 等比缩放(工单:标题字号校准)。
前端标题面板的 size 语义是"px @720p" web/src/pages/generate/types.ts 注释
apps/web/src/pages/ai-avatar/utils/titleCanvas.ts scale=videoWidth/720
输出视频宽度不是 720 1080x1920 竖屏1920x1080 横屏
drawtext 降级路径需等比缩放所有长度类字段保证成片标题视觉大小与预览一致
"""
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_720p_no_scaling(self, mock_font):
"""720p 宽度时 fontsize 保持原值。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=720)
self.assertIn("fontsize=28", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_1080p_scales_1_5x(self, mock_font):
"""1080 宽度(1080x1920 竖屏)时 size=28 → 42。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=1080)
self.assertIn("fontsize=42", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_1920p_scales_2_67x(self, mock_font):
"""1920 宽度(横屏 1920x1080)时 size=28 → 75(四舍五入)。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=1920)
self.assertIn("fontsize=75", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_width_scaled(self, mock_font):
"""描边宽度按同比例缩放。"""
mock_font.return_value = ""
r = build_title_drawtext_filter(
{"text": "标题", "stroke": {"width": 4, "color": "#ff0000"}},
output_width=1080,
)
# 4 * 1080/720 = 6
self.assertIn("borderw=6", r)
self.assertIn("bordercolor=ff0000", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_shadow_offset_scaled(self, mock_font):
"""阴影偏移按同比例缩放。"""
mock_font.return_value = ""
r = build_title_drawtext_filter(
{"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}},
output_width=1080,
)
# 5 * 1.5 = 7.5 → 8
self.assertIn("shadowx=8", r)
self.assertIn("shadowy=8", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_offsets_scaled(self, mock_font):
"""top/bottom 距边缘的 50px 边距也按比例缩放。"""
mock_font.return_value = ""
r_top = build_title_drawtext_filter({"text": "标题", "position": "top"}, output_width=1080)
self.assertIn("y=75", r_top) # 50 * 1.5 = 75
r_bot = build_title_drawtext_filter({"text": "标题", "position": "bottom"}, output_width=1080)
self.assertIn("y=h-text_h-75", r_bot)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_stroke_scaled(self, mock_font):
"""粗体黑色描边默认宽度 2 也需缩放。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "bold": True}, output_width=1080)
# bold 默认 border_width=2 → 2*1.5=3
self.assertIn("borderw=3", r)
if __name__ == "__main__":
unittest.main()