Compare commits

...

41 Commits

Author SHA1 Message Date
xiaoxia 36e8f91e5e Merge pull request 'fix(P0): 移除 MCI 插帧,60fps 视频直接输出 25fps 避免超时 504' (#2008) from fix/remove-mci-interpolation into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / 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) Successful in 2m50s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 41s
AI Code Review / AI Code Review (pull_request) Successful in 7m7s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m48s
CI/CD Pipeline / Integration Tests (push) Successful in 11m50s
CI/CD Pipeline / Validate - Style (push) Successful in 12m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 10m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 15m10s
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 19m18s
CI/CD Pipeline / Validate - Security (push) Successful in 40m0s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-21 21:21:43 +08:00
saas-backend ffb8ea6790 fix: 移除 MCI 插帧,60fps 输入直接输出 25fps 结果
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m32s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m24s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m2s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m18s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m34s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Failing after 1m55s
- 移除 Step 8 的 minterpolate MCI 运动补偿插帧(CPU密集,343帧超6分钟导致超时504)
- 高帧率视频降帧至 25fps 后直接输出,对口型数字人场景完全够用
- 插帧失败不再导致整个任务 504,直接降级返回 25fps 结果
2026-09-21 19:22:52 +08:00
xiaoxia cc01a5da69 perf: RTX3060 12G显存适配,batch_size上限提升到8 (#2007)
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 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m0s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m4s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Validate - Style (push) Successful in 4m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 5m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
CI/CD Pipeline / Validate - Security (push) Successful in 7m22s
CI/CD Pipeline / Unit Tests (push) Successful in 11m7s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-21 19:09:01 +08:00
xiaoxia 7babea3d24 fix: musetalk_server 60fps降帧+超时终止+health GFPGAN字段 (#2006)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m27s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m29s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m15s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / Validate - Style (push) Successful in 4m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m43s
CI/CD Pipeline / Validate - Security (push) Successful in 12m19s
CI/CD Pipeline / Unit Tests (push) Successful in 14m6s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / 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-21 17:24:13 +08:00
xiaoxia a9f596fca3 feat(tts): style语气风格参数全链路接入 + 语速/音量/音调透传修复 (#2005)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / 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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 1m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / 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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 5m58s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8m10s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m32s
CI/CD Pipeline / Validate - Style (push) Successful in 36m8s
CI/CD Pipeline / Unit Tests (push) Successful in 41m59s
CI/CD Pipeline / Integration Tests (push) Successful in 42m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 46m4s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 49m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 7m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 20m52s
CI/CD Pipeline / Validate - Security (push) Successful in 2h17m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-21 04:23:07 +08:00
xiaoxia 9ba036abb3 feat(#2001): 爆款标题样式配置面板后端支持 (#2004)
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 9s
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 14s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
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 1m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 51s
CI/CD Pipeline / Validate - Style (push) Successful in 5m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m1s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
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
PR Automation / Auto Merge on CI Green + Approved (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 / 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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Build Staging Worker 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
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Validate - Security (push) Successful in 10m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m40s
CI/CD Pipeline / Unit Tests (push) Successful in 12m39s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
API Base Image Build / Build API Base Image (push) Successful in 31m37s
Worker Base Image Build / Build Worker Base Image (push) Successful in 38m53s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-21 03:01:24 +08:00
saas-backend-agent 3424e55a32 feat(gpu): #1978 MuseTalk v1.5 + GFPGAN face enhancement + ffmpeg pipe optimization (#2003)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 9m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 7m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Integration Tests (push) Successful in 41m58s
CI/CD Pipeline / Validate - Style (push) Successful in 42m28s
CI/CD Pipeline / Unit Tests (push) Failing after 50m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 55m52s
CI/CD Pipeline / Validate - Security (push) Successful in 1h27m56s
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
- MuseTalk v1.5 完整推理pipeline(DWPose+SFD GPU人脸检测)
- GFPGANv1.4 人脸超分增强(FP16, 可通过 MUSE_USE_GFPGAN=0 关闭)
- ffmpeg rawvideo pipe 编码替代PNG磁盘IO,省~2s
- 异步推理架构 + /cancel 端点 + 线程锁并发控制
- 长音频驱动架构,禁止ffmpeg预循环视频(避免16x性能退化)

# Conflicts:
#	deploy/gpu_worker/musetalk_server.py
2026-09-21 00:09:57 +08:00
xiaoxia 8c715474f4 feat(web): #2001 爆款标题样式配置面板升级(P0+P1) (#2002)
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 / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) 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 / PR Build API Image (pull_request) Successful in 16s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 19s
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 / PR Build Worker Image (pull_request) Successful in 19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m53s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 33s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
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 / CI Gate (pull_request) Successful in 2s
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 / Build Staging Web Image (push) Successful in 41s
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 Worker Image (push) Successful in 36s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 8m8s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m45s
CI/CD Pipeline / Validate - Style (push) Successful in 27m21s
CI/CD Pipeline / Integration Tests (push) Successful in 26m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 31m35s
CI/CD Pipeline / Unit Tests (push) Failing after 35m54s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-20 23:09:04 +08:00
ying 93cb3e12a0 feat(gpu): add GFPGAN face enhancement + ffmpeg pipe optimization
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m43s
AI Code Review / AI Code Review (pull_request) Successful in 7m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m26s
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 15s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 10s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 22m19s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 18m23s
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 / Unit Tests (pull_request) Successful in 26m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 29m43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 10s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7m54s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 1h28m58s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- Add GFPGANv1.4 face super-resolution (FP16, +170MB VRAM, ~64ms/frame)
  - Controllable via MUSE_USE_GFPGAN env var (default enabled)
  - Enhances generated face region before blending with original frame
- Replace PNG disk I/O with ffmpeg stdin pipe encoding (~2s faster)
  - Frames streamed directly to ffmpeg as raw BGR24, no temp files
  - Eliminates output_frames_dir/ PNG write/read cycle
- Total warm inference: 54s for 5s video (was 56s with PNG, 48s without GFPGAN)
- VRAM usage: ~3.3GB with MuseTalk+GFPGAN loaded (RTX 2060 6GB OK)
2026-09-20 22:51:02 +08:00
ying e7bf85ca86 fix(gpu): #1978 MuseTalk v1.5 推理修复与Worker稳定性补丁
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m27s
AI Code Review / AI Code Review (pull_request) Successful in 7m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 8m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 39m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 40m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 44m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1h5m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
musetalk_server.py:
- v3 API兼容性:支持新的请求格式(audio_url/video_url字段)
- v4 正确pipeline:使用vae.get_latents_for_unet()生成8ch输入
- v4.4 VRAM优化:batch_size=2 + CPU offload + PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
- RTX2060 6GB可稳定运行,22s视频推理约204s(0.11x实时)

gpu_worker.py:
- 修复健康检查响应格式适配musetalk_server
- REQUEST_TIMEOUT提升到900s,与服务端GPU_TASK_TIMEOUT_SECONDS对齐
- TASK_MAX_RETRY降到1,避免与服务端MAX_ATTEMPTS=3相乘放大重试
- 增加TASK_HEARTBEAT_INTERVAL=30s,推理期间独立线程心跳防超时
- 增加MIN_VIDEO_DURATION_SECONDS=3s前置拦截,避免1s视频division by zero
2026-09-20 21:29:31 +08:00
xiaoxia 38ffa0b98b fix(1978): register lipsync_gpu task in worker celery imports
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
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 / 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 1m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 47s
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 / Validate - Python (mypy + alembic) (push) Successful in 3m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Integration Tests (push) Successful in 3m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m7s
AI Code Review / AI Code Review (pull_request) Successful in 6m49s
CI/CD Pipeline / Validate - Security (push) Successful in 7m56s
CI/CD Pipeline / Unit Tests (push) Failing after 10m23s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Worker celery_app.conf.imports was missing app.tasks.lipsync_gpu,
so lipsync_gpu_process_async.apply_async() messages were sent to
broker but never consumed by any worker, leaving lipsync_jobs stuck
at status=processing forever.
2026-09-20 13:44:10 +08:00
xiaoxia c9876d70e4 fix(1978): fix xiaoxia-gpu-worker.service bad unit file & log path permissions
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) 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 / 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 / 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 1m58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m45s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m9s
CI/CD Pipeline / Integration Tests (push) Successful in 4m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m41s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m37s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 39s
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 / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
- User=%i -> User=ying (%%i is template-only specifier, not valid here)
- After=musetalk.service -> After=musetalk-worker.service (match actual service name)
- Log path from /tmp/ to $HOME/ to avoid permission issues for ying user
2026-09-20 13:36:36 +08:00
xiaoxia 8413713315 feat(web): TTS配音情感风格选择器 (#2000)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m38s
CI/CD Pipeline / Validate - Style (push) Successful in 3m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m0s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m50s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 38s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m44s
CI/CD Pipeline / Validate - Security (push) Successful in 8m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m7s
CI/CD Pipeline / Unit Tests (push) Failing after 9m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 23m36s
feat(web): add TTS emotion style selector (#2000)
2026-09-20 12:52:47 +08:00
xiaoxia 9d8c6260e3 perf: async GPU lipsync inference + fix 16x performance regression (#1997) (#1998)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (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 if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
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 / Frontend Lint (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 / 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 37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 53s
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 / Build Staging Worker Image (push) Successful in 30s
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 / PR Build Web Image (pull_request) Successful in 1m13s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
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 / Frontend Unit Tests (push) Successful in 4m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m23s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Validate - Style (push) Successful in 4m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m3s
CI/CD Pipeline / Validate - Security (push) Successful in 8m45s
CI/CD Pipeline / Unit Tests (push) Failing after 10m3s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / 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-20 12:19:35 +08:00
xiaoxia 0764a7820c Merge pull request 'feat(deploy): GPU节点自动部署配置文件入库(musetalk-worker+gpu-poll+setup/update脚本)' (#1999) from feat/gpu-node-auto-deploy into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
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 / Frontend Unit Tests (push) Successful in 2m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 52s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Staging 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 21s
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 / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 11m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 7m56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m20s
CI/CD Pipeline / Integration Tests (push) Successful in 41m51s
CI/CD Pipeline / Validate - Style (push) Successful in 42m31s
CI/CD Pipeline / Unit Tests (push) Failing after 47m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 55m51s
CI/CD Pipeline / Validate - Security (push) Successful in 1h10m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-20 11:02:08 +08:00
xiaoxia db621b4fcb feat(deploy): #1978 GPU节点自动部署配置文件入库
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 / Check if frontend-only change (pull_request) Successful in 2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
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 1m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
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
AI Code Review / AI Code Review (pull_request) Successful in 7m47s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 8m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m37s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 29m54s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 28m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 27m55s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 24s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 40m20s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 1h36m46s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
新增 deploy/gpu_worker/ 下 3 个 systemd 单元 + 3 个运维脚本:
- musetalk-worker.service: MuseTalk 推理 API systemd 服务
- gpu-poll.service + gpu-poll.timer: 每30秒轮询Gitea最新commit
- scripts/update-gpu-worker.sh: 备份→拉代码→重启→健康检查→失败回滚
- scripts/poll_and_update.sh: SHA比对触发update
- scripts/setup-gpu-node.sh: 新节点一键初始化(apt依赖+目录+systemd+sudo免密+首次启动)
- README.md 追加第七章「自动部署」说明服务架构/部署步骤/更新机制/日志/注意事项

不改动现有 gpu_worker.py/musetalk_server.py/requirements.txt/xiaoxia-gpu-worker.service/.env.example
不改动CI/Docker/镜像构建。脚本路径写死/home/ying,后续多节点再参数化。
2026-09-20 10:28:24 +08:00
xiaoxia 60cacdf280 style: black formatting
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
AI Code Review / AI Code Review (pull_request) Successful in 7m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 8m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 12m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 33m17s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 33m31s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 33m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 40m34s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
2026-09-20 10:10:54 +08:00
xiaoxia 08de0d9946 perf: async GPU lipsync inference + fix 16x performance regression
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m16s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- Move GPU wait_for_result to Celery background task (lipsync_gpu_process_async)
  POST /lipsync/jobs now returns <1s instead of blocking 200s+
- Rewrite musetalk_server.py: MuseTalk receives full audio directly
  (v2 architecture) — no pre-looping video before inference
  Output video length = audio length, mux is fast stream copy
- Frontend polls GET /lipsync/jobs/{id} for status updates
- refresh_job_status: GPU async path (processing + no mediakit_task_id)
  skips MediaKit polling; stale jobs (>30min) auto-marked failed
- 21 unit tests pass (11 GPU integration + 10 musetalk audio mux)

Co-Authored-By: Coze <coze-opensource@bytedance.com>
2026-09-20 09:43:47 +08:00
xiaoxia b0b81a5d60 fix(1978): MuseTalk 封装强制替换为 TTS 驱动音轨 + 音频长于视频时循环画面 (#1997)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 53s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 59s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
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 / 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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
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 / Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m15s
CI/CD Pipeline / Validate - Style (push) Successful in 4m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m55s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Validate - Security (push) Successful in 7m26s
CI/CD Pipeline / Unit Tests (push) Successful in 1h52m21s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: backend-dev <dev@xiaoxiajianji.com>
Co-committed-by: backend-dev <dev@xiaoxiajianji.com>
2026-09-20 02:27:44 +08:00
xiaoxia d959dd874f feat(1895): 暂停积分系统 ENABLE_CREDIT_SYSTEM=false(保留全部代码/表/接口) (#1996)
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 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - 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 1m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m18s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m37s
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
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 1m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m58s
CI/CD Pipeline / Integration Tests (push) Successful in 4m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m26s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m9s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m7s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Validate - Security (push) Successful in 9m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m4s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
Co-authored-by: backend-dev <dev@xiaoxiajianji.com>
Co-committed-by: backend-dev <dev@xiaoxiajianji.com>
2026-09-20 01:39:02 +08:00
xiaoxia dcd0c56827 feat(web): 暂停积分板块UI展示,保留代码 (ENABLE_CREDIT_SYSTEM=false) (#1995)
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 54s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Build Production Worker Image (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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
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 / Validate - Python (mypy + alembic) (push) Successful in 4m0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m55s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m7s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m34s
CI/CD Pipeline / Validate - Style (push) Successful in 4m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Validate - Security (push) Successful in 8m57s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-20 01:17:50 +08:00
xiaoxia 585bab9313 fix(ai-avatar): bump lipsync job creation timeout to 120s (#1994)
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 (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / 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 / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m59s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m45s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / Validate - Style (push) Successful in 4m49s
CI/CD Pipeline / Integration Tests (push) Successful in 6m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m10s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Validate - Security (push) Successful in 9m5s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-20 00:43:08 +08:00
xiaoxia 4a449ae496 fix(1978): GPU lipsync 结果 URL 签 7 天 + 外部 TTS 音频转存自家 OSS (#1993)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / 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 / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m14s
CI/CD Pipeline / CI Gate (pull_request) Successful in 29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Validate - Style (push) Successful in 5m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m5s
CI/CD Pipeline / Integration Tests (push) Successful in 5m36s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m10s
CI/CD Pipeline / Validate - Security (push) Successful in 10m50s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
Co-authored-by: backend-dev <dev@xiaoxiajianji.com>
Co-committed-by: backend-dev <dev@xiaoxiajianji.com>
2026-09-20 00:07:36 +08:00
xiaoxia 112f0eb277 feat(gpu): #1978 AI数字人口型同步接入MuseTalk GPU Worker(业务侧集成) (#1991)
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 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 (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / 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 / PR Build Web Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m13s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m41s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m17s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m5s
CI/CD Pipeline / Integration Tests (push) Successful in 9m58s
CI/CD Pipeline / Validate - Style (push) Successful in 10m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m57s
CI/CD Pipeline / Validate - Security (push) Successful in 31m27s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
2026-09-19 22:43:57 +08:00
xiaoxia 2e2d1cd73e Merge pull request 'fix(gpu): #1970 重写 MuseTalk 服务端 + 客户端超时取消,修复 8 项工程 bug' (#1992) from fix/1970-musetalk-server-rewrite into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / 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 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 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
CI/CD Pipeline / Build Staging API Image (push) Successful in 43s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / Integration Tests (push) Successful in 7m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m51s
CI/CD Pipeline / Validate - Style (push) Successful in 8m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m45s
CI/CD Pipeline / Unit Tests (push) Successful in 14m35s
CI/CD Pipeline / Validate - Security (push) Successful in 28m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-19 20:05:13 +08:00
xiaoxia 32473485d7 fix(gpu): #1970 重写 MuseTalk 服务端 + 客户端超时取消,修复 8 项工程 bug
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m34s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m36s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m59s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m18s
服务端新建 deploy/gpu_worker/musetalk_server.py(替代原 ~/projects/MuseTalk/worker.py):
1. Flask app.run(threaded=True):推理阻塞时 /health 仍可达
2. _get_video_fps 兜底:ffprobe 返回 0 或失败时 fallback 到 default_fps(25)
3. _run_ffmpeg 统一封装:subprocess.run(check=True) + timeout,失败/超时抛 RuntimeError
4. inference_lock 并发锁:多请求同时到达时第二请求立即 503
5. 推理超时控制:thread.join(timeout=inference_timeout) 默认 600s,超时返回 504
6. finally 块清理临时目录:成功/失败/超时都删除 task_dir
7. 无人脸检测兜底:_run_inference 中帧提取后校验,无帧直接抛错返回 500
8. 上传大小限制:视频 <=100MB / 音频 <=20MB,超限返回 413
9. 新增 POST /cancel 端点:终止当前推理、清理临时文件、释放锁
10. GET /health 返回 GPU 显存信息(nvidia-smi)+ 当前任务状态

客户端 deploy/gpu_worker/gpu_worker.py 配套:
- _call_musetalk 超时后 POST /cancel 终止服务端僵尸推理
- _call_musetalk 返回 (ok, duration, err, retryable) 四元组
- _handle_task 仅 retryable=True 时重试,4xx/短视频等确定性失败直接上报
- 新增 _cancel_musetalk_task 辅助方法

测试:新增 15 个单测覆盖服务端全部修复点;全量 15854 passed / 28 skipped

部署提醒:用户需在 RTX2060 上 wget 新 musetalk_server.py 替换旧 worker.py 并重启服务。
2026-09-19 19:35:23 +08:00
xiaoxia 1591259bb8 fix(gpu): #1970 MuseTalk worker 推理期心跳/超时900/重试收敛/短视频前置失败 (#1990)
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 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (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 53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m41s
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 / PR Build Worker Image (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m12s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / Integration Tests (push) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / Validate - Style (push) Successful in 3m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
AI Code Review / AI Code Review (pull_request) Successful in 6m44s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m35s
CI/CD Pipeline / Validate - Security (push) Successful in 8m47s
CI/CD Pipeline / Unit Tests (push) Successful in 10m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-19 15:06:51 +08:00
xiaoxia a1f25a4426 feat(worker): #1970 AI 标签 backfill 支持 force 重打降级记录 (#1989)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
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 / 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 / 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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m14s
CI/CD Pipeline / Validate - Style (push) Successful in 3m39s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Validate - Security (push) Successful in 5m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m22s
CI/CD Pipeline / Unit Tests (push) Successful in 10m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-19 10:49:21 +08:00
xiaoxia 65a77e3fb6 Merge branch 'feat/add-doubao-vision-model-env' into develop
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Integration Tests (push) Successful in 2m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m19s
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
PR Automation / Auto Merge on CI Green + Approved (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 / 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 / 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 3m3s
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 Worker Image (push) Successful in 3m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m46s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m51s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Validate - Security (push) Successful in 8m16s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m5s
CI/CD Pipeline / Unit Tests (push) Successful in 10m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
# Conflicts:
#	scripts/render_env.sh
2026-09-19 10:32:46 +08:00
xiaoxia 9a57b0d5b8 feat: add DOUBAO_VISION_MODEL env to staging/production deployment
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m34s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m12s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m9s
- Add DOUBAO_VISION_MODEL placeholder to .env.staging and .env.production
- Add DOUBAO_VISION_MODEL to render_env.sh SHARED_SECRETS
- Add DOUBAO_VISION_MODEL secret ref in ci-pipeline.yml (staging + production)
- Uses existing endpoint ep-20260721114705-b568m which supports vision
2026-09-19 10:23:22 +08:00
xiaoxia 4d98e98b57 fix(worker): #1970 注册 AI 标签 Celery 任务(worker.tag_atom_clip unregistered) (#1987)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 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 / 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 / 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 25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Production Browser E2E (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 / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m37s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
AI Code Review / AI Code Review (pull_request) Successful in 6m47s
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 (push) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / Validate - Style (push) Successful in 2m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m12s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 31s
CI/CD Pipeline / Validate - Security (push) Successful in 3m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Unit Tests (push) Successful in 11m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / 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-19 08:42:07 +08:00
xiaoxia 81e1eb47fb test(e2e): migrate to asset-libraries + /upload APIs (#1986)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 44s
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 API Image (push) Successful in 1m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m0s
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 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m22s
CI/CD Pipeline / Validate - Style (push) Successful in 4m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / Validate - Security (push) Successful in 9m19s
CI/CD Pipeline / Unit Tests (push) Successful in 9m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12m26s
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m11s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 16m0s
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-19 02:11:46 +08:00
xiaoxia d3e4d6a07d feat: #1970 hflip 按 atom_clip ai_tags.has_text 放开 + 修复 develop migration 双头 (#1985)
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 / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
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 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) 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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m32s
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 / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m38s
CI/CD Pipeline / Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m50s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m52s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
AI Code Review / AI Code Review (pull_request) Successful in 7m14s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
2026-09-19 02:03:59 +08:00
xiaoxia 0d6ce433d0 fix: #1970 删除漏删的重复 migration 081_atom_clip_ai_tags(正确版已编号为 082) (#1984)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m10s
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 / 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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
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 / Validate - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 56s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m57s
CI/CD Pipeline / Validate - Security (push) Successful in 11m55s
CI/CD Pipeline / Unit Tests (push) Successful in 12m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 26m13s
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-19 01:20:23 +08:00
CI Bot eb2b009b33 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m9s
AI Code Review / AI Code Review (pull_request) Successful in 9m6s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m58s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m56s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m58s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 42s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m49s
2026-09-18 17:15:42 +00:00
xiaoxia fbd89b4089 feat: #1970 hflip 按 atom_clip ai_tags.has_text 放开 + 清理重复 081 migration
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m34s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 4m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m58s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m18s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m37s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- render_adapter 按非 audio 源片段顺序批量查 atom_clip.ai_tags,
  仅 has_text 显式 false 标记无文字,其余(未打标签/true/null/查询失败)保守不翻转
- UnifiedRenderService 新增 clip_has_text 注入,None 维持 P1 全保守语义
- 删除残留 081_atom_clip_ai_tags.py(与 GPU PR 的 081 撞号,内容已由 082 承载),
  develop alembic 恢复单 head:080→081_add_gpu_lipsync→082_atom_clip_ai_tags
- 新增 19 个测试(纯函数混合标记/服务门控/适配器解析/失败回退),全量 15819 passed
2026-09-19 01:06:46 +08:00
xiaoxia 9b50e0696e test(e2e): 更新冒烟测试适配 #1970 智能剪辑新5步流程 (#1983)
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 (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) 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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build Worker Image (pull_request) Successful in 58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m3s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m14s
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 1m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 45s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 4m24s
CI/CD Pipeline / Validate - Style (push) Successful in 4m31s
CI/CD Pipeline / Integration Tests (push) Failing after 5m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Validate - Security (push) Successful in 11m43s
CI/CD Pipeline / Unit Tests (push) Successful in 13m28s
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-19 00:58:17 +08:00
xiaoxia 9af73dcd86 fix: #1970 migration 编号冲突修复 081→082 (down_revision 链入 081_add_gpu_lipsync)
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 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / 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 / 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 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 / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
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 / Frontend Unit Tests (push) Successful in 4m8s
CI/CD Pipeline / Integration Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Style (push) Successful in 6m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 2h51m21s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2h58m15s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2h58m58s
CI/CD Pipeline / Unit Tests (push) Failing after 2h59m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 2h59m1s
CI/CD Pipeline / Validate - Security (push) Failing after 2h59m2s
CI/CD Pipeline / Staging E2E Tests (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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
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 2m28s
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
2026-09-18 21:27:04 +08:00
xiaoxia 6002f7a5e4 fix(gpu): result接口上报不存在task返回404而非500 2026-09-18 21:25:31 +08:00
xiaoxia 7e88440ca9 feat: #1970 片段级 AI 标签 + 叙事加权匹配 + 冗余核查 (#1981)
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 / Check if frontend-only change (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 / 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 / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Integration Tests (push) Failing after 7m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Successful in 8m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 13m44s
CI/CD Pipeline / Unit Tests (push) Successful in 14m12s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
feat: #1970 片段级 AI 标签 + 叙事加权匹配

- atom_clip_tagger.py: MediaKit 抽帧 + 豆包视觉 API 识别
- narrative_match.py: AI 标签加权匹配 (2.0 vs 1.0)
- Celery 链式触发 + 批量回填脚本
- migration 081 加 ai_tags 列
- 42 新测试,全量 15796 passed
2026-09-18 21:08:01 +08:00
xiaoxia fbf8844f25 feat(gpu): #1978 MuseTalk GPU Worker 反向轮询对接(后端API + Worker脚本) (#1979)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 2s
AI Code Review / AI Code Review (pull_request) Failing after 3s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
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 / CI Gate (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m31s
CI/CD Pipeline / Integration Tests (push) Successful in 7m15s
CI/CD Pipeline / Validate - Style (push) Successful in 7m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m57s
CI/CD Pipeline / Unit Tests (push) Successful in 13m56s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 19:59:49 +08:00
133 changed files with 12489 additions and 831 deletions
+24 -4
View File
@@ -198,10 +198,13 @@ DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# ==================== 积分/会员系统 (#1895) ====================
# 积分扣点总开关:默认 false对现有用户零影响)。
# P2 阶段各业务路由逐个接入 @points_gate 时,用
# `if settings.points_enabled: ...`
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开
# 积分系统总开关:默认 false暂停积分系统)。
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
ENABLE_CREDIT_SYSTEM=false
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
POINTS_ENABLED=false
# ==================== 抖音解析多源轮询 (#1963) ====================
@@ -213,3 +216,20 @@ TIKHUB_API_KEY=
# apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务
APIZERO_API_KEY=
# ==================== GPU MuseTalk Worker(反向轮询口型同步)====================
# GPU Worker 长期鉴权 TokenWorker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
GPU_WORKER_TOKEN=
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
# #1970RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
GPU_TASK_TIMEOUT_SECONDS=900
# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径;
# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync
USE_GPU_LIPSYNC=false
# 业务侧轮询 GPU 任务结果的间隔(秒)
GPU_LIPSYNC_POLL_INTERVAL=5
# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit
GPU_LIPSYNC_WAIT_TIMEOUT=1200
# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线
GPU_WORKER_STALE_SECONDS=300
+4
View File
@@ -1186,10 +1186,12 @@ jobs:
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
run: |
set -eu
echo "Rendering .env from template + secrets..."
@@ -1640,10 +1642,12 @@ jobs:
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
run: |
set -eu
echo "Rendering .env from template + secrets..."
@@ -0,0 +1,58 @@
"""add gpu_lipsync_tasks and gpu_workers tables for MuseTalk reverse-poll worker
Revision ID: 081_add_gpu_lipsync
Revises: 080_edit_plan_clips_atom_clip_id
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "081_add_gpu_lipsync"
down_revision = "080_edit_plan_clips_atom_clip_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
# GPU Worker 注册表
op.create_table(
"gpu_workers",
sa.Column("worker_id", sa.String(100), primary_key=True),
sa.Column("hostname", sa.String(200), nullable=False, server_default=""),
sa.Column("gpu_name", sa.String(200), nullable=False, server_default=""),
sa.Column("free_vram_mb", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("capabilities", sa.String(500), nullable=False, server_default=""),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True, index=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# GPU 口型同步任务表
op.create_table(
"gpu_lipsync_tasks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("lipsync_job_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("user_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("result_url", sa.Text(), nullable=False, server_default=""),
sa.Column("result_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("worker_id", sa.String(100), nullable=False, server_default="", index=True),
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_gpu_lipsync_status_created", "gpu_lipsync_tasks", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_gpu_lipsync_status_created", table_name="gpu_lipsync_tasks")
op.drop_table("gpu_lipsync_tasks")
op.drop_table("gpu_workers")
+26
View File
@@ -0,0 +1,26 @@
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
Revision ID: 082_atom_clip_ai_tags
Revises: 081_add_gpu_lipsync
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "082_atom_clip_ai_tags"
down_revision = "081_add_gpu_lipsync"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"asset_atom_clips",
sa.Column("ai_tags", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "ai_tags")
@@ -0,0 +1,26 @@
"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config
Revision ID: 083_cover_title_config
Revises: 082_atom_clip_ai_tags
Create Date: 2026-09-20
"""
import sqlalchemy as sa
from alembic import op
revision = "083_cover_title_config"
down_revision = "082_atom_clip_ai_tags"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ai_avatar_render_jobs",
sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
)
def downgrade() -> None:
op.drop_column("ai_avatar_render_jobs", "cover_title_config")
@@ -0,0 +1,26 @@
"""lipsync_jobs 新增 style 字段(TTS 语气风格)
Revision ID: 084_lipsync_jobs_style
Revises: 083_cover_title_config
Create Date: 2026-09-21
"""
import sqlalchemy as sa
from alembic import op
revision = "084_lipsync_jobs_style"
down_revision = "083_cover_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"lipsync_jobs",
sa.Column("style", sa.String(length=32), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("lipsync_jobs", "style")
+6
View File
@@ -14,6 +14,7 @@ from app.api.routes.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.gpu_lipsync import router as gpu_lipsync_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
@@ -211,3 +212,8 @@ api_router.include_router(
prefix="/usage",
tags=["Usage"],
)
api_router.include_router(
gpu_lipsync_router,
prefix="/gpu",
tags=["GPU Worker"],
)
@@ -63,6 +63,7 @@ def create_render_job(
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
title_config=body.title_config,
cover_config=body.cover_config,
cover_title_config=body.cover_title_config,
project_id=body.project_id,
)
except AiAvatarRenderError as exc:
+231
View File
@@ -0,0 +1,231 @@
"""GPU MuseTalk Worker 反向轮询路由 — /api/v1/gpu/lipsync/*.
仅面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
鉴权方式:长期 API Token`Authorization: Bearer <GPU_WORKER_TOKEN>`),不走用户 JWT。
接口:
POST /api/v1/gpu/register Worker 注册/心跳
GET /api/v1/gpu/lipsync/poll Worker 轮询拉任务(无任务返回 204)
POST /api/v1/gpu/lipsync/result Worker multipart 上传结果视频/上报失败
GET /api/v1/gpu/lipsync/status/{id} 业务侧查询任务状态(内部接口,暂开放给登录用户)
"""
from __future__ import annotations
import logging
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional
import requests
from app.core.storage import get_storage_service
from app.dependencies import get_db_session
from app.schemas.gpu_lipsync import (
GpuLipsyncPollResponse,
GpuLipsyncResultResponse,
GpuLipsyncStatusResponse,
GpuLipsyncTaskPayload,
GpuWorkerRegisterRequest,
GpuWorkerRegisterResponse,
)
from app.services.gpu_lipsync_service import GpuLipsyncService
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
Request,
UploadFile,
status,
)
from fastapi.responses import Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
router = APIRouter()
# 复用 bearer scheme 抽 Token,但不校验用户 JWT
_gpu_bearer = HTTPBearer(auto_error=False)
def _verify_gpu_token(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_gpu_bearer),
) -> str:
"""校验 GPU Worker Token,返回 worker 提供的 token 串(仅用于日志,不做身份识别).
- development 且未配置 token → 直接放行(方便本地调试)。
- production/staging 未配置 token → 拒绝(避免裸奔)。
- token 不匹配 → 401。
"""
settings = get_api_settings()
expected = (settings.gpu_worker_token or "").strip()
is_dev = settings.environment == "development"
if not expected:
if is_dev:
return credentials.credentials if credentials else ""
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GPU_WORKER_TOKEN not configured on server",
)
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
if credentials.credentials != expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid GPU worker token")
return credentials.credentials
def _get_svc(db=Depends(get_db_session)) -> GpuLipsyncService:
return GpuLipsyncService(db)
# ── POST /register — Worker 注册/心跳 ──────────────────────────────
@router.post("/register", response_model=GpuWorkerRegisterResponse)
def register_worker(
body: GpuWorkerRegisterRequest,
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
svc.register_worker(
worker_id=body.worker_id,
hostname=body.hostname,
gpu_name=body.gpu_name,
free_vram_mb=body.free_vram_mb,
capabilities=body.capabilities,
task_id=body.task_id,
)
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok")
# ── GET /lipsync/poll — Worker 轮询拉任务 ─────────────────────────
@router.get("/lipsync/poll")
def poll_task(
worker_id: str = Query(..., min_length=1, max_length=100, description="Worker 唯一 ID"),
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
task = svc.poll_task(worker_id=worker_id)
if task is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
payload = GpuLipsyncTaskPayload(
task_id=task.id,
video_url=getattr(task, "_signed_video_url", task.video_url),
audio_url=getattr(task, "_signed_audio_url", task.audio_url),
lipsync_job_id=task.lipsync_job_id or "",
user_id=task.user_id or "",
project_id=task.project_id or "",
created_at=task.created_at,
upload_url=getattr(task, "_signed_upload_url", ""),
upload_method="PUT",
expires_at=getattr(task, "_upload_expires_at", datetime.now(UTC)),
)
return GpuLipsyncPollResponse(task=payload)
# ── POST /lipsync/result — Worker 上报结果(multipart) ─────────────
@router.post("/lipsync/result", response_model=GpuLipsyncResultResponse)
async def report_result(
request: Request,
task_id: str = Form(...),
worker_id: str = Form(...),
success: bool = Form(True),
duration_seconds: float = Form(0.0),
error_msg: str = Form(""),
result: Optional[UploadFile] = File(None),
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
# 参数校验:
# - success=true + result 文件 → API 代为上传到 OSS(方便 Worker 端实现)
# - success=true + 无文件 → Worker 已经自己 PUT 到预签名 upload_url,直接确认
# - success=false → 不上传文件,错误信息通过 error_msg 传递
if success and result is not None:
# 把文件落盘到临时目录,然后 PUT 到预签名 URL
storage = get_storage_service()
result_key = svc._result_key(task_id)
upload_url = storage.get_upload_url(result_key, expires_seconds=3600, content_type="video/mp4")
try:
with tempfile.TemporaryDirectory(prefix="gpu_result_") as tmpdir:
tmp_path = Path(tmpdir) / "result.mp4"
content = await result.read()
if not content:
raise HTTPException(status_code=400, detail="上传的 result 文件为空")
tmp_path.write_bytes(content)
headers = {"Content-Type": "video/mp4"}
with open(tmp_path, "rb") as f:
resp = requests.put(upload_url, data=f, headers=headers, timeout=300)
if resp.status_code >= 400:
logger.error(
"上传 GPU 结果到 OSS 失败: status=%d body=%s",
resp.status_code,
resp.text[:500],
)
raise HTTPException(
status_code=502,
detail=f"上传结果视频到 OSS 失败 (HTTP {resp.status_code})",
)
except HTTPException:
raise
except Exception as exc:
logger.exception("上传 GPU 结果视频异常: %s", exc)
raise HTTPException(status_code=500, detail=f"上传结果视频异常: {exc}") from exc
elif not success:
# 失败时忽略 result 文件(即便传了也没用)
pass
# 其他情况:success=true 且无文件 → Worker 已自行 PUT 到预签名 URL,直接标记完成
try:
task = svc.report_result(
task_id=task_id,
worker_id=worker_id,
success=success,
duration_seconds=duration_seconds,
error_msg=error_msg,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return GpuLipsyncResultResponse(
ok=True,
task_id=task.id,
status=task.status,
message="ok",
)
# ── GET /lipsync/status/{task_id} — 业务侧查询状态 ─────────────────
# 说明:此接口会被 lipsync_service 内部在业务流程里直接读 DB,不通过 HTTP。
# 但仍暴露一个简单查询接口,方便调试和前端轮询(如后续需要)。暂不做用户权限校验,
# task_id 本身是 UUID,不可枚举。
@router.get("/lipsync/status/{task_id}", response_model=GpuLipsyncStatusResponse)
def get_task_status(
task_id: str,
svc: GpuLipsyncService = Depends(_get_svc),
):
task = svc.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return GpuLipsyncStatusResponse(
task_id=task.id,
status=task.status,
result_url=task.result_url,
result_duration=task.result_duration,
error_msg=task.error_msg,
worker_id=task.worker_id,
attempt=task.attempt,
created_at=task.created_at,
started_at=task.started_at,
finished_at=task.finished_at,
)
+4
View File
@@ -111,6 +111,8 @@ def create_lipsync_job(
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
@@ -211,6 +213,8 @@ def preview_tts(
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
)
except MediaKitError as exc:
+33 -6
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import get_db_session
from app.schemas.points import (
DailyUsageResponse,
@@ -44,6 +45,12 @@ from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
def _credits_enabled() -> bool:
"""积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。"""
return bool(getattr(settings, "points_enabled", False))
# ── 两个 router ──
points_router = APIRouter()
usage_router = APIRouter()
@@ -172,6 +179,19 @@ def check_points(
"valid_scenes": sorted(POINTS_SCENES.keys()),
},
)
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
if not _credits_enabled():
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
return PointsCheckResponse(
allowed=True,
required_points=0,
current_balance=account["balance"],
remaining_after=account["balance"],
is_free_quota=False,
)
is_mem = _is_member(current_user)
mt = _member_type(current_user)
@@ -209,8 +229,19 @@ def deduct_points(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""积分扣减(内部服务调用)。"""
"""积分扣减(内部服务调用)。
积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变,
直接返回成功,保证内部调用方拿到 success=True 继续业务流程。
"""
svc = _get_service()
if not _credits_enabled():
account = svc.get_or_create_account(current_user.user.id, db)
return SimpleMessageResponse(
success=True,
message="积分系统已暂停,未扣减积分",
data={"transaction_id": "", "balance": account["balance"]},
)
result = svc.deduct_points(
user_id=current_user.user.id,
amount=body.amount,
@@ -243,11 +274,7 @@ def refund_points(
"""积分退还(内部服务调用)。"""
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
txn = (
db.query(PointsTransactionModel)
.filter(PointsTransactionModel.id == body.transaction_id)
.first()
)
txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first()
if txn is None:
raise HTTPException(status_code=404, detail="交易记录不存在")
if txn.user_id != current_user.user.id:
+6
View File
@@ -207,6 +207,9 @@ def synthesize(
synthesis_meta = {
"speed": request.speed,
"emotion": request.emotion or "",
"style": request.style or "",
"volume": request.volume if request.volume is not None else 50,
"pitch": request.pitch if request.pitch is not None else 1.0,
"language": request.language or "zh-CN",
}
if request.metadata_:
@@ -654,6 +657,9 @@ def preview_tts(
text=request.text,
voice_id=actual_voice_id,
speed=request.speed,
style=request.style or "",
volume=request.volume if request.volume is not None else 50,
pitch=request.pitch,
emotion=request.emotion,
language=getattr(request, "language", "zh-CN"),
)
+7 -1
View File
@@ -53,9 +53,14 @@ class CreateAiAvatarRenderRequest(BaseModel):
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field(
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL"
default_factory=dict,
description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)",
)
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
cover_title_config: dict[str, Any] = Field(
default_factory=dict,
description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题",
)
project_id: str = Field("", description="项目 ID")
@field_validator("lipsync_job_id")
@@ -83,6 +88,7 @@ class AiAvatarRenderJobResponse(BaseModel):
b_roll_segments: list[dict[str, Any]]
title_config: dict[str, Any]
cover_config: dict[str, Any]
cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置")
status: str
progress: int
output_video_url: str
+111
View File
@@ -0,0 +1,111 @@
"""GPU MuseTalk 反向轮询 API Schema 定义.
面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
Worker 用长期 GPU_WORKER_TOKEN 鉴权(不是用户 JWT)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ── Worker 注册/心跳 ──────────────────────────────────────────────
class GpuWorkerRegisterRequest(BaseModel):
"""Worker 启动/心跳时上报自身信息."""
worker_id: str = Field(..., min_length=1, max_length=100, description="Worker 唯一 ID(机器名+UUID 等)")
hostname: str = Field("", max_length=200, description="主机名,用于运维排查")
gpu_name: str = Field("", max_length=200, description="GPU 型号,如 'NVIDIA GeForce RTX 2060'")
free_vram_mb: int = Field(0, ge=0, description="当前空闲显存(MB")
capabilities: str = Field("musetalk", max_length=500, description="能力列表,逗号分隔,如 'musetalk'")
task_id: Optional[str] = Field(
None,
max_length=64,
description=(
"当前正在处理的任务 ID。Worker 推理期间定期心跳时携带,"
"服务端同步刷新该任务 last_heartbeat_at,防止长推理被误判超时;空闲时不传"
),
)
class GpuWorkerRegisterResponse(BaseModel):
ok: bool = True
server_time: datetime
message: str = "ok"
# ── 轮询任务 ────────────────────────────────────────────────────
class GpuLipsyncTaskPayload(BaseModel):
"""下发给 Worker 的任务载荷(含预签名下载 URL)."""
task_id: str
video_url: str = Field(..., description="人物视频预签名下载 URLGET")
audio_url: str = Field(..., description="驱动音频预签名下载 URLGET")
lipsync_job_id: str = ""
user_id: str = ""
project_id: str = ""
created_at: datetime
upload_url: str = Field(..., description="结果视频预签名上传 URLPUT, video/mp4")
upload_method: str = Field("PUT", description="上传方式,目前只支持 PUT")
expires_at: datetime
class GpuLipsyncPollResponse(BaseModel):
"""Worker poll 的返回:200 带任务,204 无任务."""
task: Optional[GpuLipsyncTaskPayload] = None
# ── Worker 上报结果 ──────────────────────────────────────────────
class GpuLipsyncResultRequest(BaseModel):
"""Worker 通过 multipart 上传结果时携带的字段(非文件字段)."""
task_id: str = Field(..., min_length=1, max_length=64)
worker_id: str = Field(..., min_length=1, max_length=100)
success: bool = Field(True, description="true=成功(此时必须上传 result 视频文件);false=失败")
duration_seconds: float = Field(0.0, ge=0, description="合成后视频时长(秒),成功时应填入")
error_msg: str = Field("", max_length=2000, description="失败原因,success=false 时必填")
class GpuLipsyncResultResponse(BaseModel):
ok: bool = True
task_id: str
status: str # done / failed
message: str = "ok"
# ── 业务侧查询任务状态 ────────────────────────────────────────────
class GpuLipsyncStatusResponse(BaseModel):
task_id: str
status: str
result_url: str = ""
result_duration: float = 0.0
error_msg: str = ""
worker_id: str = ""
attempt: int = 0
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# ── 创建任务(内部服务调用) ──────────────────────────────────────
class GpuLipsyncCreateRequest(BaseModel):
"""服务层内部创建 GPU 任务用(不通过 HTTP 暴露给 Worker/前端)."""
video_url: str # 已可访问的 OSS key 或公网 URL(API 侧会转预签名)
audio_url: str
lipsync_job_id: str = ""
user_id: str = ""
project_id: str = ""
+13 -2
View File
@@ -29,6 +29,7 @@ class LipsyncJobResponse(BaseModel):
voice_id: str = ""
script_text: str = ""
speed: float = 1.0
style: str = ""
emotion: str = ""
mediakit_task_id: str
status: str
@@ -67,9 +68,14 @@ class CreateLipsyncJobRequest(BaseModel):
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID")
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field(
"",
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
description="[deprecated] 旧情绪参数,内部映射为 style",
)
enable_video_loop: bool = Field(
@@ -123,10 +129,15 @@ class AiAvatarTtsPreviewRequest(BaseModel):
voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID")
script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field(
"neutral",
max_length=32,
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral",
description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral",
)
+14 -3
View File
@@ -16,9 +16,15 @@ class TTSSynthesizeRequest(BaseModel):
output_name: str = Field("", description="输出文件名")
language: str = Field("zh-CN", description="语言")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
pitch: Optional[float] = Field(None, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
emotion: str = Field(
"",
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style",
)
voice_model: str = Field("", description="语音模型名称")
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
@@ -113,9 +119,14 @@ class TTSPreviewRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field("", description="[deprecated] 旧情绪参数,内部映射为 style")
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
class TTSPreviewResponse(BaseModel):
@@ -61,6 +61,7 @@ class AiAvatarRenderService:
b_roll_segments: list[dict[str, Any]] | None = None,
title_config: dict[str, Any],
cover_config: dict[str, Any],
cover_title_config: dict[str, Any] | None = None,
project_id: str = "",
) -> AiAvatarRenderJob:
"""创建渲染任务.
@@ -112,6 +113,7 @@ class AiAvatarRenderService:
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
title_config=title_config,
cover_config=cover_config,
cover_title_config=cover_title_config or {},
status="pending",
)
self.db.add(job)
@@ -0,0 +1,382 @@
"""GPU MuseTalk 口型同步服务 — 反向轮询模式.
职责:
1. 创建任务(由 lipsync 业务流程调用),为输入/输出生成预签名 URL,任务入队;
2. Worker 心跳注册(register):登记/刷新 worker 状态;
3. Worker 轮询拉任务(poll):原子地 CLAIM 一条 pending 任务,返回预签名 URL
4. Worker 上报结果(report_result):标记 done/failed,失败可重试;
5. 业务侧查询状态(get_status)。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Optional
from app.core.storage import get_storage_service
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
# 任务在 processing 超过此时长仍未完成 → 超时回退 pending 或置 failed
MAX_ATTEMPTS = 3
class GpuLipsyncService:
"""GPU 口型同步服务(无状态方法,每次调用从 DI 拿 db/storage."""
RESULT_PREFIX = "gpu-lipsync/results/"
INPUT_SIGN_EXPIRES_PAD = 600 # 输入预签名 URL 在任务超时基础上再加 10min 余量
# ── 公共入口 ────────────────────────────────────────────────────
def __init__(self, db: Session):
self.db = db
self.settings = get_api_settings()
self.storage = get_storage_service()
# ── Worker 注册/心跳 ────────────────────────────────────────────
def register_worker(
self,
worker_id: str,
hostname: str = "",
gpu_name: str = "",
free_vram_mb: int = 0,
capabilities: str = "musetalk",
task_id: Optional[str] = None,
) -> GpuWorkerModel:
"""Worker 注册/心跳。
task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing
任务的 last_heartbeat_at 续到当前时间,使长推理不会被
``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker
(如已被超时回收重新派发)时忽略,不报错。
"""
now = datetime.now(UTC)
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
if worker is None:
worker = GpuWorkerModel(
worker_id=worker_id,
hostname=hostname,
gpu_name=gpu_name,
free_vram_mb=free_vram_mb,
capabilities=capabilities,
last_heartbeat_at=now,
created_at=now,
)
self.db.add(worker)
else:
worker.hostname = hostname or worker.hostname
worker.gpu_name = gpu_name or worker.gpu_name
worker.free_vram_mb = free_vram_mb
worker.capabilities = capabilities or worker.capabilities
worker.last_heartbeat_at = now
if task_id:
self._touch_task_heartbeat(task_id, worker_id, now)
self.db.commit()
return worker
# ── 轮询拉任务(Worker 调用) ──────────────────────────────────
def poll_task(self, worker_id: str) -> Optional[GpuLipsyncTaskModel]:
"""原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None.
同时会:
- 把 processing 状态且真正超时(任务心跳停滞超过
gpu_task_timeout_secondsWorker 推理期会通过 register(task_id=...)
续心跳,长推理不会误判)的任务回退为 pending(attempt++,超过
MAX_ATTEMPTS 置 failed),让其它 worker 认领。
- 刷新 worker 心跳。
"""
now = datetime.now(UTC)
self._recover_timed_out_tasks(now)
# 更新 worker 心跳
self._touch_worker(worker_id, now)
# 选一条最早 pending 任务(FOR UPDATE SKIP LOCKED 语义:简单起见先查再锁状态)
task = (
self.db.query(GpuLipsyncTaskModel)
.filter(GpuLipsyncTaskModel.status == "pending")
.order_by(GpuLipsyncTaskModel.created_at.asc())
.first()
)
if task is None:
self.db.commit()
return None
# 原子 claim:用 UPDATE WHERE status=pending 避免并发
upd_rows = (
self.db.query(GpuLipsyncTaskModel)
.filter(
GpuLipsyncTaskModel.id == task.id,
GpuLipsyncTaskModel.status == "pending",
)
.update(
{
GpuLipsyncTaskModel.status: "processing",
GpuLipsyncTaskModel.worker_id: worker_id,
GpuLipsyncTaskModel.started_at: now,
GpuLipsyncTaskModel.last_heartbeat_at: now,
GpuLipsyncTaskModel.attempt: GpuLipsyncTaskModel.attempt + 1,
GpuLipsyncTaskModel.updated_at: now,
},
synchronize_session=False,
)
)
self.db.commit()
if upd_rows == 0:
# 被其它 worker 抢先了
return None
self.db.refresh(task)
# 生成预签名输入/输出 URL(在 claim 时动态生成,避免长时间过期)
expires = self.settings.gpu_task_timeout_seconds + self.INPUT_SIGN_EXPIRES_PAD
task._signed_video_url = self.storage.get_download_url(task.video_url, expires_seconds=expires)
task._signed_audio_url = self.storage.get_download_url(task.audio_url, expires_seconds=expires)
task._signed_upload_url = self.storage.get_upload_url(
self._result_key(task.id),
expires_seconds=expires,
content_type="video/mp4",
)
task._upload_expires_at = now + timedelta(seconds=expires)
return task
# ── 上报结果 ──────────────────────────────────────────────────
def report_result(
self,
task_id: str,
worker_id: str,
success: bool,
duration_seconds: float = 0.0,
error_msg: str = "",
) -> GpuLipsyncTaskModel:
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
raise KeyError(f"task {task_id} not found")
now = datetime.now(UTC)
if success:
task.status = "done"
task.result_url = self._result_key(task_id)
task.result_duration = duration_seconds or 0.0
task.error_msg = ""
task.finished_at = now
else:
# 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed
if task.attempt < MAX_ATTEMPTS:
task.status = "pending"
task.worker_id = ""
task.started_at = None
task.error_msg = error_msg[:2000]
logger.warning(
"GPU 任务 %s 在 worker %s 上失败,回退 pending 等待重试(attempt=%d: %s",
task_id,
worker_id,
task.attempt,
error_msg[:200],
)
else:
task.status = "failed"
task.error_msg = error_msg[:2000]
task.finished_at = now
logger.error(
"GPU 任务 %s 失败达到最大重试次数 %d,置为 failed: %s",
task_id,
MAX_ATTEMPTS,
error_msg[:200],
)
task.updated_at = now
task.last_heartbeat_at = now
self._touch_worker(worker_id, now)
self.db.commit()
self.db.refresh(task)
return task
# ── 业务侧查询 ────────────────────────────────────────────────
def get_task(self, task_id: str) -> Optional[GpuLipsyncTaskModel]:
return self.db.get(GpuLipsyncTaskModel, task_id)
def get_by_lipsync_job(self, lipsync_job_id: str) -> Optional[GpuLipsyncTaskModel]:
return (
self.db.query(GpuLipsyncTaskModel)
.filter(GpuLipsyncTaskModel.lipsync_job_id == lipsync_job_id)
.order_by(GpuLipsyncTaskModel.created_at.desc())
.first()
)
# ── 创建任务(业务侧调用) ────────────────────────────────────
def create_task(
self,
video_url: str,
audio_url: str,
lipsync_job_id: str = "",
user_id: str = "",
project_id: str = "",
) -> GpuLipsyncTaskModel:
task_id = str(uuid.uuid4())
now = datetime.now(UTC)
task = GpuLipsyncTaskModel(
id=task_id,
lipsync_job_id=lipsync_job_id,
user_id=user_id,
project_id=project_id,
video_url=video_url,
audio_url=audio_url,
status="pending",
attempt=0,
created_at=now,
updated_at=now,
)
self.db.add(task)
self.db.commit()
self.db.refresh(task)
logger.info(
"创建 GPU 口型任务 %s (lipsync_job=%s, user=%s)",
task_id,
lipsync_job_id,
user_id,
)
return task
# ── 内部辅助 ──────────────────────────────────────────────────
def _result_key(self, task_id: str) -> str:
return f"{self.RESULT_PREFIX}{task_id}.mp4"
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> None:
"""Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。
任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的
结果上报会被结果接口按最终态处理)。
"""
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
return
if task.status != "processing" or task.worker_id != worker_id:
logger.info(
"忽略过期任务心跳 task=%s worker=%sstatus=%s owner=%s",
task_id,
worker_id,
task.status,
task.worker_id,
)
return
task.last_heartbeat_at = now
task.updated_at = now
self.db.flush()
def _touch_worker(self, worker_id: str, now: datetime) -> None:
if not worker_id:
return
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
if worker is not None:
worker.last_heartbeat_at = now
self.db.flush()
else:
# 自注册(poll 时允许自动建一个空 worker 记录,运维可见)
worker = GpuWorkerModel(
worker_id=worker_id,
hostname="",
gpu_name="",
free_vram_mb=0,
capabilities="musetalk",
last_heartbeat_at=now,
created_at=now,
)
self.db.add(worker)
self.db.flush()
def _recover_timed_out_tasks(self, now: datetime) -> None:
"""扫描 processing 状态且真正超时的任务,回退 pending 或失败。
判定只看任务自身 last_heartbeat_atclaim 时写入,Worker 推理期间通过
/gpu/register(task_id=...) 每 30s 续期。因此仅在 Worker 崩溃/断网
(任务心跳停滞超过 gpu_task_timeout_seconds)时才回收,
不会因 Worker 主循环忙于推理而误回退。
"""
timeout = self.settings.gpu_task_timeout_seconds
cutoff = now - timedelta(seconds=timeout)
stuck_tasks = (
self.db.query(GpuLipsyncTaskModel)
.filter(
GpuLipsyncTaskModel.status == "processing",
GpuLipsyncTaskModel.last_heartbeat_at < cutoff,
)
.all()
)
for t in stuck_tasks:
if t.attempt >= MAX_ATTEMPTS:
t.status = "failed"
t.error_msg = f"worker 心跳超时({timeout}s),重试次数已耗尽"
t.finished_at = now
else:
t.status = "pending"
t.worker_id = ""
t.started_at = None
t.error_msg = f"worker 心跳超时({timeout}s),等待重试"
logger.warning("GPU 任务 %s 心跳超时,回退 pendingattempt=%d", t.id, t.attempt)
t.updated_at = now
if stuck_tasks:
self.db.flush()
# ── 业务侧辅助 ──────────────────────────────────────────────────
def has_available_worker(self) -> bool:
"""判断是否有 Worker 在心跳新鲜窗口内可用."""
stale_cutoff = datetime.now(UTC) - timedelta(seconds=self.settings.gpu_worker_stale_seconds)
return (
self.db.query(GpuWorkerModel).filter(GpuWorkerModel.last_heartbeat_at >= stale_cutoff).first() is not None
)
def wait_for_result(
self,
task_id: str,
timeout_seconds: Optional[int] = None,
poll_interval: Optional[float] = None,
) -> Optional[GpuLipsyncTaskModel]:
"""同步轮询等待 GPU 任务完成。
Args:
task_id: 任务 ID(由 create_task 返回)
timeout_seconds: 总超时,默认取 settings.gpu_lipsync_wait_timeout
poll_interval: 轮询间隔秒,默认取 settings.gpu_lipsync_poll_interval
Returns:
终态 taskstatus=done/failed);超时返回 None(此时调用方应回退 MediaKit)。
等待期间会自动调用 _recover_timed_out_tasks 做超时回收。
"""
import time
timeout = timeout_seconds if timeout_seconds is not None else self.settings.gpu_lipsync_wait_timeout
interval = poll_interval if poll_interval is not None else self.settings.gpu_lipsync_poll_interval
deadline = time.monotonic() + timeout
while True:
now = datetime.now(UTC)
# 顺手回收超时任务
try:
self._recover_timed_out_tasks(now)
self.db.commit()
except Exception as exc: # noqa: BLE001 - 回收失败不阻塞主流程
logger.warning("wait_for_result 回收超时任务异常: %s", exc)
self.db.rollback()
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
return None
if task.status == "done":
return task
if task.status == "failed":
return task
# pending/processing 继续等
if time.monotonic() >= deadline:
logger.warning("GPU 任务 %s 等待超时(%ds),回退 MediaKit", task_id, timeout)
return None
time.sleep(interval)
+203 -2
View File
@@ -29,6 +29,7 @@ from app.services.mediakit_client import (
MediaKitError,
get_mediakit_client,
)
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
from app.tasks.lipsync_tts import tts_synthesize_and_submit
@@ -36,6 +37,7 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError
from packages.config import get_api_settings
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
@@ -63,6 +65,7 @@ class LipsyncService:
self.client = client or get_mediakit_client()
self._cosyvoice = cosyvoice_service
self._voice_clone_repo = voice_clone_repo
self.settings = get_api_settings()
def _get_cosyvoice(self):
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
@@ -108,6 +111,8 @@ class LipsyncService:
script_text: str,
speed: float,
emotion: str,
style: str = "",
volume: int = 50,
) -> str:
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
@@ -121,7 +126,9 @@ class LipsyncService:
text=script_text,
voice_id=actual_voice_id,
speed=speed,
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
style=style,
volume=volume,
emotion=emotion,
language="zh",
)
except CosyVoiceError as exc:
@@ -215,7 +222,57 @@ class LipsyncService:
if timings:
job.sentence_timings = timings
# 4. 签名 URL 并提交 MediaKit
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
use_gpu = False
if self.settings.use_gpu_lipsync:
try:
from app.services.gpu_lipsync_service import GpuLipsyncService
gpu_svc = GpuLipsyncService(self.db)
if gpu_svc.has_available_worker():
use_gpu = True
logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id)
else:
logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id)
except Exception as exc:
logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc)
if use_gpu:
try:
gpu_task = self._submit_to_gpu_create(job=job, gpu_svc=gpu_svc)
if gpu_task is not None:
# GPU 任务已创建,设为 processing 并异步等待结果
job.mediakit_task_id = f"gpu:{gpu_task.id}"
job.status = "processing"
job.updated_at = datetime.now(UTC)
self.db.commit()
# 派发 Celery 异步任务处理 GPU 等待+结果回写
try:
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
logger.info(
"[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s",
job.id,
gpu_task.id,
)
except Exception as celery_exc:
logger.warning(
"[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s",
job.id,
celery_exc,
)
self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task)
return
# create 失败 → 回退 MediaKit
logger.warning("[lipsync] GPU 任务创建失败,回退 MediaKit: job_id=%s", job.id)
self.db.rollback()
except Exception as exc:
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
try:
self.db.rollback()
except Exception:
pass
# 5. 签名 URL 并提交 MediaKit(兜底路径)
video_url = self._sign_media_url(job.video_url)
signed_audio_url = self._sign_media_url(job.audio_url)
job.audio_url = signed_audio_url
@@ -244,6 +301,120 @@ class LipsyncService:
self.db.commit()
raise
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
def _is_own_oss_url(self, url: str, storage) -> bool:
"""判断 URL / 存储 key 是否属于自家 OSS。
- 裸存储 key(无 scheme):自家对象
- host 与 storage.public_url host 一致:自家对象
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
"""
if not url:
return False
parsed = urlparse(url)
if not parsed.scheme:
return True # 裸存储 key
public_base = getattr(storage, "public_url", "")
own_host = urlparse(public_base).netloc.lower() if public_base else ""
return bool(own_host) and parsed.netloc.lower() == own_host
def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]:
"""GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。
Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址
可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。
已是自家 OSS 对象(含裸 key)直接返回 None(无需转存);
转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败,
服务端重试耗尽后回退 MediaKit,不阻断业务)。
"""
if self._is_own_oss_url(job.audio_url, storage):
return None
try:
audio_data = safe_download_bytes(
job.audio_url,
purpose="lipsync_gpu_tts_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info(
"[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s",
job.id,
storage_key,
)
return permanent_url
except Exception as exc:
logger.warning(
"[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s",
job.id,
exc,
)
return None
def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]:
"""创建 GPU 任务并立即返回(异步模式)。
成功返回 gpu_task 对象;创建失败返回 None。
不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。
"""
storage = get_shared_storage_service()
persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage)
audio_url_for_task = persisted_audio_url or job.audio_url
gpu_task = gpu_svc.create_task(
video_url=job.video_url,
audio_url=audio_url_for_task,
lipsync_job_id=job.id,
user_id=job.user_id,
project_id=job.project_id,
)
logger.info(
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
job.id,
gpu_task.id,
)
return gpu_task
def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None:
"""同步等待 GPU 结果(Celery 派发失败时的降级路径)。"""
final_task = gpu_svc.wait_for_result(gpu_task.id)
if final_task is None:
logger.warning("[lipsync] GPU 同步等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
return
if final_task.status != "done":
logger.warning(
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
gpu_task.id,
final_task.status,
)
return
try:
storage = get_shared_storage_service()
signed_result_url = storage.get_download_url(
final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS
)
if signed_result_url:
final_task.result_url = signed_result_url
except Exception as exc:
logger.warning(
"[lipsync] GPU 结果签名失败: gpu_task=%s err=%s",
gpu_task.id,
exc,
)
job.mediakit_task_id = ""
job.status = STATUS_COMPLETED
job.output_video_url = final_task.result_url
job.output_duration = final_task.result_duration or 0.0
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
logger.info(
"[lipsync] GPU 同步等待完成: job_id=%s duration=%.2f",
job.id,
job.output_duration,
)
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -257,6 +428,8 @@ class LipsyncService:
voice_id: str = "",
script_text: str = "",
speed: float = 1.0,
style: str = "",
volume: int = 50,
emotion: str = "",
enable_video_loop: bool = True,
project_id: str = "",
@@ -305,6 +478,7 @@ class LipsyncService:
voice_id=voice_id or "",
script_text=script_text or "",
speed=speed,
style=style or "",
emotion=emotion or "",
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending",
@@ -326,6 +500,8 @@ class LipsyncService:
voice_id,
script_text,
speed,
style or "",
volume,
emotion or "",
)
)
@@ -360,6 +536,8 @@ class LipsyncService:
voice_id: str,
script_text: str,
speed: float = 1.0,
style: str = "",
volume: int = 50,
emotion: str = "neutral",
) -> dict:
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
@@ -478,6 +656,29 @@ class LipsyncService:
if job.status in (STATUS_COMPLETED, "failed"):
return job
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
if job.status in ("processing", "gpu_processing"):
_now = datetime.now(UTC)
_upd = job.updated_at
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
stale_minutes = 30
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
logger.warning(
"GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s",
stale_minutes,
job_id,
)
job.status = "failed"
job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)"
job.error_code = "GpuTimeout"
job.completed_at = _now
job.updated_at = _now
self.db.commit()
return job
# 未提交的任务不轮询
if not job.mediakit_task_id:
return job
+190
View File
@@ -0,0 +1,190 @@
"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行.
优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。
任务流程:
1. 加载 LipsyncJob,获取 gpu_task_id
2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成
3. 签名结果 URL7 天),更新 job 为 completed
4. 失败/超时时:尝试 MediaKit 兜底,若仍失败则标记 job 为 failed
使用 @shared_task 确保被 Worker 侧 celery_app 正确注册。
"""
import logging
from datetime import UTC, datetime
from celery import shared_task
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
logger = logging.getLogger(__name__)
# 与 LipsyncService 保持一致
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
def _get_db_session() -> Session:
"""获取 DB session(兼容 API 和 Worker 两种运行时)."""
try:
from worker_app.db import SessionLocal # type: ignore
except ImportError:
from app.db import SessionLocal # type: ignore
return SessionLocal()
def _sign_media_url(url: str) -> str:
"""对自家 OSS URL 签 7 天预签名。"""
if not url:
return url
try:
from urllib.parse import urlparse
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
return storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
except Exception:
return url
@shared_task(
name="lipsync_gpu_process_async",
bind=True,
max_retries=0,
acks_late=True,
)
def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str) -> None:
"""异步处理 GPU MuseTalk 推理。
Args:
job_id: LipsyncJob 的 ID
user_id: 用户 ID
gpu_task_id: GpuLipsyncTask 的 ID
"""
db: Session = _get_db_session()
try:
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
if job is None:
logger.error("[lipsync_gpu_async] job 不存在: job_id=%s", job_id)
return
# 确保状态为 processing
if job.status not in ("processing", "gpu_processing"):
logger.warning(
"[lipsync_gpu_async] job 状态异常,跳过: job_id=%s status=%s",
job_id,
job.status,
)
return
from app.services.gpu_lipsync_service import GpuLipsyncService
gpu_svc = GpuLipsyncService(db)
final_task = gpu_svc.wait_for_result(gpu_task_id)
if final_task is None:
logger.warning(
"[lipsync_gpu_async] GPU 超时,回退 MediaKit: job_id=%s gpu_task=%s",
job_id,
gpu_task_id,
)
_fallback_to_mediakit(db, job)
return
if final_task.status != "done":
logger.warning(
"[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s",
job_id,
gpu_task_id,
final_task.status,
)
_fallback_to_mediakit(db, job)
return
# 签名结果 URL
result_url = final_task.result_url or ""
try:
storage = get_shared_storage_service()
signed = storage.get_download_url(result_url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
if signed:
result_url = signed
except Exception as exc:
logger.warning(
"[lipsync_gpu_async] 签名失败,用原 URL: job_id=%s err=%s",
job_id,
exc,
)
job.status = "completed"
job.output_video_url = result_url
job.output_duration = final_task.result_duration or 0.0
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[lipsync_gpu_async] GPU 完成: job_id=%s duration=%.2f",
job_id,
job.output_duration,
)
except Exception as exc:
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
try:
job = db.query(LipsyncJobModel).filter_by(id=job_id).first()
if job:
job.status = "failed"
job.error_message = f"GPU 异步处理异常: {exc}"
job.error_code = "GpuAsyncError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
finally:
db.close()
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
"""GPU 失败时回退到 MediaKit 云端渲染。"""
try:
from app.services.mediakit_client import MediaKitError, get_mediakit_client
client = get_mediakit_client()
video_url = _sign_media_url(job.video_url)
audio_url = _sign_media_url(job.audio_url)
result = client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[lipsync_gpu_async] 已回退 MediaKit: job_id=%s task_id=%s",
job.id,
result["task_id"],
)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[lipsync_gpu_async] MediaKit 也失败: job_id=%s err=%s", job.id, exc)
except Exception as exc:
job.status = "failed"
job.error_message = f"GPU+MediaKit 均失败: {exc}"
job.error_code = "FallbackFailed"
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[lipsync_gpu_async] 兜底异常: job_id=%s err=%s", job.id, exc)
+5 -1
View File
@@ -82,7 +82,9 @@ def tts_synthesize_and_submit(
voice_id: str,
script_text: str,
speed: float,
emotion: str,
style: str = "",
volume: int = 50,
emotion: str = "",
):
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
@@ -159,6 +161,8 @@ def tts_synthesize_and_submit(
text=script_text,
voice_id=voice_id,
speed=speed,
style=style,
volume=volume,
emotion=emotion,
language="zh",
)
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[douyin] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* #1972 抖音文案提取冒烟
*
* 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」
* → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案
*/
test.describe("Douyin Script Extraction (#1972)", () => {
test("extract flow: open modal, paste link, text prefilled in create modal", async ({
page,
request,
}) => {
test.setTimeout(180_000)
await page.setViewportSize({ width: 1440, height: 900 })
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-douyin-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` },
})
const token = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${token}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Douyin ${suffix}` },
})
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// Mock 抖音提取接口返回稳定文案
const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!"
await page.route("**/api/v1/scripts/extract-from-douyin", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ text: extractedText, duration_seconds: 15 }),
}),
)
// 文案列表空态
await page.route(
(url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }),
}),
)
await page.goto("/app/scripts")
// 文案库页面加载
await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 })
// 点「🎬 从抖音提取」按钮
await page.getByRole("button", { name: /从抖音提取/ }).click()
await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 })
// 在 TextArea 粘贴"抖音分享文案"
const textarea = page.locator(".ant-modal textarea").first()
await expect(textarea).toBeVisible()
await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/")
// 点「开始提取」
await page.getByRole("button", { name: "开始提取" }).click()
await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 })
// 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案
await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 })
await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 })
const createTextarea = page.locator(".ant-modal textarea").first()
await expect(createTextarea).toBeVisible()
await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10)))
console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length)
})
})
+321 -238
View File
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
@@ -8,7 +8,8 @@ const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
@@ -24,276 +25,358 @@ async function loginWithRetry(
email: string,
password: string,
maxRetries = 2,
) {
): Promise<string> {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
})
if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (resp.status() !== 429) {
expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy()
const data = await resp.json()
return data.access_token
}
console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`)
await new Promise((r) => setTimeout(r, 65000))
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
throw new Error("Login failed after retries")
}
/**
* 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。
*/
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` },
})
}
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
type ProjectResponse = { id: string }
type LibraryResponse = { id: string }
type AssetListResponse = {
items: Array<{
id: string
name: string
status: string
}>
}
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
})
expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy()
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 })
const lib = await request.post(`${apiBase}/asset-libraries`, {
headers: auth,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy()
const libraryId = (await lib.json()).id
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
test.setTimeout(360_000)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-gen-${suffix}@example.com`
const username = `e2e_gen_${suffix}`
const libraryName = `E2E Gen Lib ${suffix}`
// Register
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
})
expect(register.status()).toBe(201)
const registerData = (await register.json()) as { user_id: string }
// Login
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// Create project
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E Gen Proj ${suffix}` },
})
expect(project.status()).toBe(200)
const projectData = (await project.json()) as ProjectResponse
// Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, {
headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" },
})
expect(library.status()).toBe(200)
const libraryData = (await library.json()) as LibraryResponse
// Upload source video
const sourceFileName = "e2e-gen-source.mp4"
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, {
headers,
multipart: {
project_id: projectData.id,
library_id: libraryData.id,
file: {
name: sourceFileName,
mimeType: "video/mp4",
buffer: sampleVideoBuffer,
},
const samplePath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleBuf = fs.readFileSync(samplePath)
const up = await request.post(`${apiBase}/upload`, {
headers: auth,
multipart: {
project_id: projectId,
library_id: libraryId,
file: {
name: "sample.mp4",
mimeType: "video/mp4",
buffer: sampleBuf,
},
})
expect(upload.status()).toBe(200)
},
})
expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy()
const assetId = (await up.json()).asset_id
await expect
.poll(
async () => {
const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth })
return r.ok() ? (await r.json()).status : "pending"
},
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, libraryId, assetId, suffix }
}
// Wait for asset to be ready
await expect
.poll(
async () => {
const assets = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libraryData.id },
})
if (!assets.ok()) return `http_${assets.status()}`
const data = (await assets.json()) as AssetListResponse
const asset = data.items.find((a) => a.name === sourceFileName)
if (!asset) return "missing"
return asset.status
},
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
/**
* #1970 智能剪辑核心冒烟(新 5 步向导)
*
* 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面
*
* 两条路径:
* 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗
* → Step3 标题 → Step4 确认生成 → 断言任务创建
* 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成)
* → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成
* → 断言任务创建
*/
test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
// 确保默认模板存在(智能剪辑页依赖模板)
const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader })
const tmplsJson = await tmpls.json()
const templates = Array.isArray(tmplsJson)
? tmplsJson
: Array.isArray(tmplsJson.items)
? tmplsJson.items
: []
expect(templates.length).toBeGreaterThan(0)
// 注入登录态 + 路由 API
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
.toBe("ready")
}, token)
await routeBrowserApiToTestApi(page)
// GET /templates auto-creates a default template for new users
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
const templatesData = (await templatesResp.json()) as {
items: Array<{ id: string }>
}
expect(Array.isArray(templatesData.items)).toBe(true)
expect(templatesData.items.length).toBeGreaterThan(0)
const templateId = templatesData.items[0].id
expect(templateId).toBeTruthy()
// Set auth in localStorage
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token)
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice ──
await page.route(
(url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice",
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: `asset-voice-${suffix}`,
name: "测试配音.mp3",
file_url: "data:audio/mpeg;base64,",
duration: 10,
file_size: 1024,
kind: "voice",
status: "ready",
},
],
total: 1,
}),
)
},
{
token: loginData.access_token,
user: {
id: registerData.user_id,
user_id: registerData.user_id,
email,
username,
display_name: username,
is_email_verified: true,
email_verified: true,
},
},
}),
)
// Navigate to generate page
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 20_000,
timeout: 30000,
})
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 1: 素材选择 ──
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 })
await page.getByText("测试配音.mp3").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── 数量弹窗(PreviewCountModal ──
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 5_000,
})
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 3: 标题设置 ──
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
await page.waitForTimeout(2000)
const titleInput = page.locator(".ant-select-auto-complete input")
// ── Step 3:填写标题 ──────────────────────────────────────────
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`E2E Test ${suffix}`)
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// Step 3 底部是「下一步 →」,点击进入 Step 4确认生成
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 4确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
// ── Step 4: 确认生成 ──
// 等待实时预览就绪(占位消失)
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
const createTask = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
)
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
})
// Step 4 底部是「✨ 确认生成视频」
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
test("narrative mode: select script + mock TTS, create generation task", async ({
page,
request,
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "narrative")
// 先挂 API 监听再点击
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
},
{ timeout: 30_000 },
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
})
// 预设音色(TtsVoiceModal 查询 GET /voices/presets
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
await confirmBtn.click()
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// 验证生成 API 被调用
const genResp = await generatePromise.catch(() => null)
if (!genResp) {
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
console.log(
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
)
} else if (genResp.ok()) {
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
// race:渲染完成 vs 生成失败/超时
const downloadReady = page
.getByText("视频生成完成")
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "completed" : null))
const generationFailed = page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "failed" : null))
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
if (outcome === "completed") {
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
}
// 验证成品库页面加载
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
await page.unrouteAll({ behavior: "ignoreErrors" })
})
test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36)
const email = `e2e-gen-api-${suffix}@example.com`
const username = `e2e_gen_api_${suffix}`
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
expect(register.status()).toBe(201)
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E API Proj ${suffix}` },
})
expect(project.status()).toBe(200)
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200)
const tasksData = await tasks.json()
expect(Array.isArray(tasksData.items)).toBe(true)
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
)
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
})
})
+105
View File
@@ -0,0 +1,105 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[nav] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error)
*/
test.describe("Core Navigation", () => {
let authToken: string
test.beforeAll(async ({ request }) => {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-nav-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` },
})
authToken = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${authToken}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Nav ${suffix}` },
})
if (proj.ok()) {
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Nav Lib", kind: "video" },
})
}
})
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, authToken)
await routeBrowserApiToTestApi(page)
})
const navCases = [
{ path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" },
{ path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" },
{ path: "/app/assets", marker: /视频库|素材/, name: "视频库" },
{ path: "/app/scripts", marker: /文案/, name: "文案库" },
{ path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" },
{ path: "/app/products", marker: /成品|作品/, name: "成品库" },
{ path: "/app/history", marker: /历史|任务/, name: "任务历史" },
{ path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" },
{ path: "/app/points", marker: /积分|我的积分/, name: "积分中心" },
]
for (const c of navCases) {
test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => {
const errors: Error[] = []
page.on("pageerror", (e) => errors.push(e))
await page.goto(c.path)
await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 })
// 过滤掉常见第三方/非致命错误
const fatal = errors.filter(
(e) =>
!/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test(
e.message,
),
)
expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength(
0,
)
await expect(
page.getByText(c.marker).first(),
`${c.name} should show relevant text`,
).toBeVisible({ timeout: 15000 })
console.log(`[nav] ${c.name} loaded ✓`)
})
}
})
+13 -2
View File
@@ -7,8 +7,19 @@ export interface GenerateCoverTitleConfig {
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
italic?: boolean
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
shadow?:
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
line_height?: number
margin_top?: number
max_chars_per_line?: number
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
line_overrides?: Array<Record<string, unknown>>
cover_title_config?: Record<string, unknown>
pos_x?: number
pos_y?: number
}
export interface GenerateCoverRequest {
+29 -3
View File
@@ -81,7 +81,7 @@ export interface CreateGenerationTaskRequest {
tts_voice_source?: "preset" | "clone"
/** #1970:智能降重开关(默认 true) */
dedup_enabled?: boolean
/** 标题烧录配置 */
/** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */
title_config?: {
text?: string
font?: string
@@ -89,8 +89,34 @@ export interface CreateGenerationTaskRequest {
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
italic?: boolean
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
shadow?:
| boolean
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
line_height?: number
margin_top?: number
max_chars_per_line?: number
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
line_overrides?: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
cover_title_config?: Record<string, unknown>
pos_x?: number
pos_y?: number
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
@@ -54,6 +54,8 @@ export interface SegmentTtsConfig {
pitch: number
volume: number
subtitle_sync: boolean
/** 配音风格预设(natural/excited/professional/sweet/news/livestream */
style?: string
}
/** 片段裁剪配置 */
+3
View File
@@ -18,6 +18,9 @@ export type {
TTSPreviewResponse,
} from "./types"
export type { TtsStyle, TtsStyleOption } from "./styles"
export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles"
// API 函数
export {
synthesizeSpeech,
+71
View File
@@ -0,0 +1,71 @@
/**
* TTS 配音风格预设(情感/语气风格)
* - key:传给后端的 style 标识,便于后端按策略合成
* - 未传 style 时后端默认自然亲切
*
* 注:与原 emotionCosyVoice 7 种基础情绪枚举)解耦;
* style 是更高层的"说话风格预设",后端可能映射到 emotion + speed + prompt 组合。
*/
export interface TtsStyleOption {
/** 传给后端的风格标识 */
value: string
/** 展示名 */
label: string
/** emoji 图标 */
emoji: string
/** 给用户/后端的风格描述(prompt 风格) */
description: string
}
export const TTS_STYLE_OPTIONS: readonly TtsStyleOption[] = [
{
value: "natural",
label: "自然亲切",
emoji: "😊",
description: "亲切自然,像朋友聊天",
},
{
value: "excited",
label: "激动兴奋",
emoji: "🤩",
description: "激动兴奋,语速稍快,充满活力",
},
{
value: "professional",
label: "沉稳专业",
emoji: "🧑‍💼",
description: "沉稳专业,语速适中,正式可靠",
},
{
value: "sweet",
label: "温柔甜美",
emoji: "🌸",
description: "温柔甜美,语速轻柔",
},
{
value: "news",
label: "新闻播报",
emoji: "📰",
description: "字正腔圆,严肃正式",
},
{
value: "livestream",
label: "直播带货",
emoji: "🎤",
description: "热情有感染力,有节奏感",
},
] as const
export type TtsStyle = (typeof TTS_STYLE_OPTIONS)[number]["value"]
/** 默认风格:自然亲切 */
export const DEFAULT_TTS_STYLE: TtsStyle = "natural"
/** 根据 value 查找风格选项(容错:找不到回退 natural) */
export function getTtsStyle(value: string | null | undefined): TtsStyleOption {
return (
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[]).find((o) => o.value === value) ??
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[])[0]
)
}
+4
View File
@@ -17,6 +17,8 @@ export interface TTSSynthesizeRequest {
output_name?: string
language?: string
emotion?: string
/** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */
style?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
@@ -106,6 +108,8 @@ export interface TTSPreviewRequest {
pitch?: number
language?: string
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
/** 配音风格预设 */
style?: string
}
/** TTS 试听响应 */
@@ -17,6 +17,7 @@ import {
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { usePointsStore } from "@/store/pointsStore"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import "./PointsBadge.css"
const { Text, Paragraph } = Typography
@@ -32,9 +33,13 @@ const PointsBadge: React.FC = () => {
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
useEffect(() => {
if (!ENABLE_CREDIT_SYSTEM) return
if (!balance) init()
}, [balance, init])
// 功能开关:积分系统关闭时直接隐藏徽章
if (!ENABLE_CREDIT_SYSTEM) return null
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
const bal = membership?.points_balance ?? balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
@@ -15,6 +15,7 @@ import React, { useMemo } from "react"
import { Tooltip } from "antd"
import { WarningOutlined } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import type { PointsSource } from "@/api/points/types"
import "./PointsCost.css"
@@ -53,7 +54,7 @@ const PointsCost: React.FC<Props> = ({
compact = false,
showRechargeHint = true,
className = "",
}) => {
}: Props) => {
const { balance, dailyUsage, rules, membership } = usePointsStore()
const qty = quantity ?? units ?? 1
@@ -118,6 +119,9 @@ const PointsCost: React.FC<Props> = ({
}
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
// 积分系统关闭时不展示消耗提示(组件保留,hooks 必须在 return 前调用)
if (!ENABLE_CREDIT_SYSTEM) return null
if (!rule || !balance) {
return <span className={`xx-points-cost ${className}`} />
}
+38 -25
View File
@@ -21,6 +21,7 @@ import { useLogout } from "@/hooks/useAuth"
import type { MenuProps } from "antd"
import { NAV_ITEMS } from "@/config/navigation"
import PointsBadge from "@/components/common/PointsBadge"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import { usePointsStore } from "@/store/pointsStore"
import "./Header.css"
@@ -57,30 +58,36 @@ const Header: React.FC = () => {
label: "订阅管理",
onClick: () => navigate("/app/subscription"),
},
// v2: 我的积分入口
{
key: "points-center",
icon: <ThunderboltOutlined />,
label: (
<Space>
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
label: "充值积分",
onClick: () => navigate("/app/points/recharge"),
},
// 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points-center",
icon: <ThunderboltOutlined />,
label: (
<Space>
{balance && (
<span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>
)}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
label: "充值积分",
onClick: () => navigate("/app/points/recharge"),
},
]
: []),
{ type: "divider" },
{
key: "logout",
@@ -130,7 +137,13 @@ const Header: React.FC = () => {
{/* v2: 升级会员入口(仅免费用户显示) */}
{!isMember && (
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
<Tooltip
title={
ENABLE_CREDIT_SYSTEM
? "升级会员解锁无限混剪、批量导出,积分 8 折起"
: "升级会员解锁无限混剪、批量导出"
}
>
<Button
type="primary"
size="small"
+361
View File
@@ -0,0 +1,361 @@
/**
* 标题样式相关常量(#2001
* - 字体列表(新增4款爆款字体)
* - 色板(常用标题字色/描边色/背景色)
* - 预设样式方案(10 个,含抖音爆款黄)
*/
import type { TitleStyleConfig } from "./types"
/* ── 字体选项(#2001:新增优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy ── */
export interface FontOption {
value: string
label: string
/** CSS font-family 栈 */
family: string
/** 爆款/常用标签 */
tag?: "hot" | "new"
}
export const FONT_OPTIONS: FontOption[] = [
{
value: "优设标题黑",
label: "优设标题黑",
family:
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "阿里普惠体Bold",
label: "阿里普惠体Bold",
family:
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "抖音美好体",
label: "抖音美好体",
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "思源黑体Heavy",
label: "思源黑体Heavy",
family:
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
tag: "new",
},
{
value: "思源黑体",
label: "思源黑体",
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
},
{
value: "思源宋体",
label: "思源宋体",
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
},
{
value: "苹方",
label: "苹方",
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
},
{
value: "微软雅黑",
label: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC",sans-serif',
},
{
value: "楷体",
label: "楷体",
family: '"KaiTi","STKaiti","DFKai-SB",serif',
},
]
/** 根据中文名取 font-family 栈(找不到回退思源黑体) */
export function getFontFamily(font: string): string {
const f = FONT_OPTIONS.find((x) => x.value === font)
if (f) return f.family
return FONT_OPTIONS[4].family // 思源黑体
}
/* ── 色板 ── */
/** 标题字色(常用爆款色) */
export const TITLE_COLOR_PALETTE: string[] = [
"#ffffff",
"#000000",
"#ffd700", // 抖音黄
"#ff2d55", // 抖音红
"#ff4081",
"#00e5ff",
"#d4a843",
"#ffa500",
"#52c41a",
"#1890ff",
"#7c3aed",
"#ff6b35",
]
/** 描边色(黑/白/灰为主) */
export const STROKE_COLOR_PALETTE: string[] = [
"#000000",
"#ffffff",
"#333333",
"#555555",
"#8b0000",
"#001f3f",
]
/** 背景色(带透明度) */
export const BG_COLOR_PALETTE: string[] = [
"rgba(0,0,0,0.5)",
"rgba(0,0,0,0.7)",
"rgba(0,0,0,0.3)",
"rgba(255,215,0,0.9)",
"rgba(255,45,85,0.85)",
"rgba(124,58,237,0.85)",
"rgba(24,144,255,0.85)",
"rgba(82,196,26,0.85)",
]
/* ── 预设样式方案(10 个,含抖音爆款黄) ── */
export interface TitlePreset {
key: string
label: string
emoji: string
/** 应用时覆盖到 TitleStyleConfig 的字段(其他字段保持当前值) */
style: Partial<TitleStyleConfig>
}
const BASE: Partial<TitleStyleConfig> = {
line_overrides: [],
cover_title_config: null,
}
export const TITLE_PRESETS: TitlePreset[] = [
{
key: "douyin_hot",
label: "抖音爆款黄",
emoji: "🔥",
style: {
...BASE,
font: "优设标题黑",
size: 80,
color: "#ffd700",
bold: true,
italic: false,
stroke: true,
stroke_width: 8,
stroke_color: "#000000",
shadow: true,
shadow_offset_x: 3,
shadow_offset_y: 3,
shadow_blur: 6,
shadow_color: "rgba(0,0,0,0.6)",
bg_enabled: false,
line_height: 1.25,
max_chars_per_line: 8,
},
},
{
key: "classic_white",
label: "经典白字黑描边",
emoji: "⚪",
style: {
...BASE,
font: "思源黑体Heavy",
size: 56,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
stroke_width: 5,
stroke_color: "#000000",
shadow: false,
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 10,
},
},
{
key: "red_bold",
label: "醒目红字",
emoji: "🔴",
style: {
...BASE,
font: "优设标题黑",
size: 72,
color: "#ff2d55",
bold: true,
italic: false,
stroke: true,
stroke_width: 6,
stroke_color: "#ffffff",
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 5,
shadow_color: "rgba(0,0,0,0.5)",
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 9,
},
},
{
key: "black_gold",
label: "黑金质感",
emoji: "🟡",
style: {
...BASE,
font: "思源宋体",
size: 52,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 8,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
line_height: 1.25,
max_chars_per_line: 10,
},
},
{
key: "neon_blue",
label: "霓虹发光",
emoji: "💙",
style: {
...BASE,
font: "阿里普惠体Bold",
size: 60,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 0,
shadow_offset_y: 0,
shadow_blur: 16,
shadow_color: "#00e5ff",
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 10,
},
},
{
key: "bg_black",
label: "黑底白字",
emoji: "⬛",
style: {
...BASE,
font: "思源黑体Heavy",
size: 52,
color: "#ffffff",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: true,
bg_color: "rgba(0,0,0,0.7)",
bg_padding: 16,
bg_radius: 8,
line_height: 1.3,
max_chars_per_line: 10,
},
},
{
key: "bg_yellow",
label: "黄底黑字",
emoji: "🟨",
style: {
...BASE,
font: "抖音美好体",
size: 56,
color: "#000000",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: true,
bg_color: "rgba(255,215,0,0.95)",
bg_padding: 14,
bg_radius: 6,
line_height: 1.2,
max_chars_per_line: 9,
},
},
{
key: "sweet_pink",
label: "温柔甜美粉",
emoji: "🌸",
style: {
...BASE,
font: "阿里普惠体Bold",
size: 50,
color: "#ff4081",
bold: false,
italic: false,
stroke: true,
stroke_width: 4,
stroke_color: "#ffffff",
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(255,64,129,0.4)",
bg_enabled: false,
line_height: 1.3,
max_chars_per_line: 11,
},
},
{
key: "business_dark",
label: "商务深色",
emoji: "💼",
style: {
...BASE,
font: "思源黑体",
size: 44,
color: "#ffffff",
bold: false,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 1,
shadow_offset_y: 1,
shadow_blur: 3,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: true,
bg_color: "rgba(24,144,255,0.85)",
bg_padding: 12,
bg_radius: 4,
line_height: 1.3,
max_chars_per_line: 12,
},
},
{
key: "minimal_clean",
label: "极简无描边",
emoji: "✨",
style: {
...BASE,
font: "苹方",
size: 48,
color: "#ffffff",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: false,
line_height: 1.3,
max_chars_per_line: 10,
},
},
]
/** 根据 key 获取预设 */
export function getTitlePreset(key: string): TitlePreset | undefined {
return TITLE_PRESETS.find((p) => p.key === key)
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 共享标题样式配置类型(#2001 爆款标题样式配置面板升级)
*
* 设计原则:
* 1. 向后兼容:保留旧的 bold/stroke/shadow 布尔字段,新增细粒度字段
* stroke_width/stroke_color/shadow_offset_x-y-blur-color/bg_enabled-color-padding-radius/line_height/margin_top/max_chars_per_line)。
* 2. 后端契约:字段名使用 snake_case,与 title_config dict 直接对齐。
* 3. line_overrides 支持逐行覆盖(选中某行单独设置颜色/字号/关键词高亮/加粗/斜体)。
* 4. cover_title_config 为封面独立标题样式,null 表示封面沿用主标题样式。
*/
/** 关键词高亮配置 */
export interface TitleKeywordHighlight {
/** 要高亮的词 */
word: string
/** 高亮颜色(可选,默认主色反转) */
color?: string
/** 是否加粗(默认 true */
bold?: boolean
/** 额外字号放大倍数(1.0=不变,1.3=放大 30% */
scale?: number
}
/** 单行覆盖配置 */
export interface TitleLineOverride {
/** 行索引(0-based,按 / 或自动换行后的行序) */
line_index: number
/** 覆盖后的文字(可选,默认沿用原行) */
text?: string
/** 覆盖字号(可选) */
size?: number
/** 覆盖字色(可选) */
color?: string
/** 覆盖加粗(可选) */
bold?: boolean
/** 覆盖斜体(可选) */
italic?: boolean
/** 覆盖描边开关(可选) */
stroke?: boolean
/** 关键词高亮列表 */
highlights?: TitleKeywordHighlight[]
}
/**
* 标题样式配置(不含 title 文字本身,不含 auto_subtitle)。
*
* cover_title_config 使用 Partial<Omit<...,"cover_title_config">> 递归避免无限类型。
*/
export interface TitleStyleConfig {
/* ── 基础 ── */
font: string
size: number
color: string
bold: boolean
italic: boolean
position: "top" | "center" | "bottom" | "custom"
pos_x?: number
pos_y?: number
/* ── 排版(P0 ── */
/** 行距倍数(默认 1.2 */
line_height: number
/** 顶部边距(position=top 时距画面顶部距离,px @720p,默认 24 */
margin_top: number
/** 每行最大字符数(4-20,超出自动换行;0=不自动换行,使用 / 手动分行) */
max_chars_per_line: number
/* ── 描边参数化(P0) ── */
stroke: boolean
stroke_width: number
stroke_color: string
/* ── 阴影参数化(P1) ── */
shadow: boolean
shadow_offset_x: number
shadow_offset_y: number
shadow_blur: number
shadow_color: string
/* ── 背景色块(P1) ── */
bg_enabled: boolean
bg_color: string
bg_padding: number
bg_radius: number
/* ── 逐行独立样式(P1) ── */
line_overrides: TitleLineOverride[]
/* ── 封面独立标题配置(P1):null=沿用主标题样式 ── */
cover_title_config: null | Partial<Omit<TitleStyleConfig, "cover_title_config">>
}
/** 默认样式(经典白字黑描边,保持老版本观感) */
export const DEFAULT_TITLE_STYLE: TitleStyleConfig = {
font: "思源黑体",
size: 48,
color: "#ffffff",
bold: true,
italic: false,
position: "bottom",
line_height: 1.2,
margin_top: 24,
max_chars_per_line: 0,
stroke: true,
stroke_width: 4,
stroke_color: "#000000",
shadow: false,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
bg_color: "rgba(0,0,0,0.5)",
bg_padding: 12,
bg_radius: 8,
line_overrides: [],
cover_title_config: null,
}
@@ -0,0 +1,151 @@
/**
* TTS 配音风格选择器
* - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货)
* - 卡片单选,选中高亮紫色
* - 默认 natural
*
* 复用方式:
* <TtsStyleSelector value={style} onChange={setStyle} />
* <TtsStyleSelector value={style} onChange={setStyle} compact /> // 紧凑模式(小尺寸)
*/
import React from "react"
import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
export interface TtsStyleSelectorProps {
value?: TtsStyle | string
onChange: (style: TtsStyle) => void
/** 紧凑模式(小卡片),适合与其他参数并排 */
compact?: boolean
/** 是否显示"配音风格"标签 */
showLabel?: boolean
}
const TtsStyleSelector: React.FC<TtsStyleSelectorProps> = ({
value,
onChange,
compact = false,
showLabel = true,
}) => {
const current = value || DEFAULT_TTS_STYLE
if (compact) {
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 6,
}}
>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 6,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "6px 4px",
borderRadius: 6,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#374151",
cursor: "pointer",
fontSize: 12,
fontWeight: selected ? 600 : 400,
textAlign: "center",
transition: "all 0.15s",
lineHeight: 1.3,
}}
>
<span style={{ marginRight: 3 }}>{opt.emoji}</span>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 8,
fontWeight: 500,
}}
>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 8,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "10px 8px",
borderRadius: 8,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#111",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<span style={{ fontSize: 22, lineHeight: 1 }}>{opt.emoji}</span>
<span style={{ fontSize: 13, fontWeight: selected ? 600 : 500 }}>{opt.label}</span>
<span
style={{
fontSize: 10,
color: "#9ca3af",
lineHeight: 1.2,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{opt.description}
</span>
</button>
)
})}
</div>
</div>
)
}
export default TtsStyleSelector
+13
View File
@@ -0,0 +1,13 @@
/**
* 功能开关配置
* 集中管理前端特性的启用/隐藏,便于灰度与回滚。
* 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。
*/
/**
* 积分系统 UI 开关(默认 false = 隐藏)
* - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留;
* 功能流程不做积分预校验,直接走生成。
* - true:展示完整积分系统 UI。
*/
export const ENABLE_CREDIT_SYSTEM = false
+23 -12
View File
@@ -3,6 +3,7 @@
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
*/
import React from "react"
import { ENABLE_CREDIT_SYSTEM } from "./features"
import {
DashboardOutlined,
FileOutlined,
@@ -105,12 +106,17 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
: []),
]
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -200,12 +206,17 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
: []),
],
},
]
+9 -12
View File
@@ -30,11 +30,7 @@ import {
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob, SentenceTiming } from "./types"
import {
normalizeEmotion,
buildTitleConfigPayload,
buildCoverConfigPayload,
} from "./utils/contract"
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
/** 面板折叠状态 */
@@ -94,7 +90,7 @@ const AiAvatarPage: React.FC = () => {
state.resetTtsPreview()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style])
const _clearTtsProgressTimer = useCallback(() => {
if (ttsProgressTimerRef.current) {
@@ -148,7 +144,7 @@ const AiAvatarPage: React.FC = () => {
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
style: state.style,
})
_clearTtsProgressTimer()
setTtsProgress(100)
@@ -175,7 +171,7 @@ const AiAvatarPage: React.FC = () => {
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style])
const handleRetryTts = useCallback(() => {
handleGenerateTts()
@@ -254,7 +250,7 @@ const AiAvatarPage: React.FC = () => {
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
style: state.style,
}
}
const job = await createLipsyncJob(payload)
@@ -298,7 +294,8 @@ const AiAvatarPage: React.FC = () => {
state.selectedVoice,
state.scriptText,
state.speed,
state.emotion,
state.style,
state.ttsPreview,
])
@@ -598,8 +595,8 @@ const AiAvatarPage: React.FC = () => {
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
style={state.style}
onStyleChange={state.setStyle}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
+5 -1
View File
@@ -41,10 +41,13 @@ export const createLipsyncJob = async (data: {
speed?: number
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgustedTTS 直生模式用;前端经 normalizeEmotion 归一化) */
emotion?: string
/** 配音风格预设(natural/excited/professional/sweet/news/livestream */
style?: string
enable_video_loop?: boolean
project_id?: string
}): Promise<LipsyncJob> => {
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
// GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data, { timeout: 120_000 })
return response.data
}
@@ -54,6 +57,7 @@ export const previewTts = async (data: {
script_text: string
speed?: number
emotion?: string
style?: string
}): Promise<{
audio_url: string
duration: number
@@ -29,15 +29,7 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */
const FONT_FAMILY_MAP: Record<string, string> = {
:
"'Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif",
: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
: "KaiTi, 'STKaiti', serif",
: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
}
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants"
export function PanelLipsyncPreview({
lipsyncJob,
@@ -83,63 +75,105 @@ export function PanelLipsyncPreview({
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致 */
const titleOverlayStyle: React.CSSProperties | null =
/** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行 */
const titleOverlayData =
titleConfig?.title && containerWidth > 0
? (() => {
const c = titleConfig as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
cover_title_config?: Record<string, unknown> | null
line_overrides?: unknown[]
}
const baseSize = titleConfig.size || 48
const fontSize = ps(baseSize)
// 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p
const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2))
// 阴影按比例缩放
const shadowBlur = ps(4)
const shadowOffsetY = ps(2)
// padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放)
const padV = ps(16) * 0.5 // ≈ 8px in ~240px container
const padH = ps(24) * 0.5
const strokeW = c.stroke ? ps(c.stroke_width ?? 4) : 0
const strokeC = c.stroke_color || "#000000"
const shBlur = ps(c.shadow_blur ?? 4)
const shOffX = ps(c.shadow_offset_x ?? 2)
const shOffY = ps(c.shadow_offset_y ?? 2)
const shColor = c.shadow_color || "rgba(0,0,0,0.8)"
const lh = c.line_height ?? 1.2
const mTop = ps(c.margin_top ?? 24)
const bgPad = ps(c.bg_padding ?? 12)
const bgR = ps(c.bg_radius ?? 8)
const maxChars = c.max_chars_per_line ?? 0
const rawText = titleConfig.title || ""
const lines = (() => {
const manual = rawText
.split(/[/]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
manual.forEach((seg) => {
for (let i = 0; i < seg.length; i += maxChars) out.push(seg.slice(i, i + maxChars))
})
return out
})()
const padV = ps(16) * 0.5
const textShadow = titleConfig.shadow
? `${shOffX}px ${shOffY}px ${shBlur}px ${shColor}`
: undefined
const style: React.CSSProperties = {
position: "absolute",
color: titleConfig.color || "#ffffff",
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"),
fontSize: `${fontSize}px`,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
width: "90%",
lineHeight: 1.2,
padding: `${ps(4)}px ${padH}px`,
textShadow: titleConfig.shadow
? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)`
: undefined,
WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined,
boxSizing: "border-box",
wordBreak: "break-word",
lineHeight: lh,
WebkitTextStroke:
titleConfig.stroke && strokeW > 0 ? `${strokeW}px ${strokeC}` : undefined,
paintOrder: "stroke fill",
textShadow,
whiteSpace: "pre-wrap",
padding: c.bg_enabled ? `${bgPad}px ${bgPad}px` : 0,
background: c.bg_enabled ? c.bg_color || "rgba(0,0,0,0.5)" : "transparent",
borderRadius: c.bg_enabled ? `${bgR}px` : 0,
boxSizing: "border-box",
display: "inline-block",
maxWidth: "94%",
}
const wrap: React.CSSProperties = {
position: "absolute",
left: "50%",
width: "100%",
display: "flex",
justifyContent: "center",
pointerEvents: onTitlePositionChange ? "auto" : "none",
}
if (
titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
) {
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translateX(-50%) translateY(-50%)"
wrap.left = `${titleConfig.pos_x}%`
wrap.top = `${titleConfig.pos_y}%`
wrap.transform = "translate(-50%, -50%)"
} else if (titleConfig.position === "top") {
style.left = "50%"
style.top = padV
style.transform = "translateX(-50%)"
wrap.top = `${padV + mTop}px`
wrap.transform = "translateX(-50%)"
} else if (titleConfig.position === "bottom") {
style.left = "50%"
style.bottom = padV
style.transform = "translateX(-50%)"
wrap.bottom = `${padV}px`
wrap.transform = "translateX(-50%)"
} else {
style.left = "50%"
style.top = "50%"
style.transform = "translateX(-50%) translateY(-50%)"
wrap.top = "50%"
wrap.transform = "translate(-50%, -50%)"
}
return style
return { style, wrap, lines }
})()
: null
@@ -252,25 +286,23 @@ export function PanelLipsyncPreview({
{isDone && lipsyncJob?.output_video_url ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<video src={lipsyncJob.output_video_url} controls />
{titleOverlayStyle && (
{titleOverlayData && (
<div
ref={titleDragRef}
style={{
...titleOverlayStyle,
...titleOverlayData.wrap,
cursor: onTitlePositionChange ? "grab" : "default",
pointerEvents: onTitlePositionChange ? "auto" : "none",
}}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
{titleConfig!.title.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
<div style={titleOverlayData.style}>
{titleOverlayData.lines.map((part: string, i: number) => (
<div key={i}>{part}</div>
))}
</div>
</div>
)}
</div>
@@ -13,7 +13,8 @@ import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleSettings } from "@/pages/generate/types"
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
import { POSITION_OPTIONS } from "@/pages/generate/constants"
import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants"
import type { AiAvatarTitleConfig } from "../types"
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
import { getScripts } from "@/api/scripts"
@@ -49,9 +50,60 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
.catch(() => setTitleOptions([]))
}, [])
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
const titleSettings: TitleSettings = useMemo(
() => ({
/** AiAvatarTitleConfig (snake_case) → TitleSettings (camelCase) */
const titleSettings: TitleSettings = useMemo(() => {
const c = titleConfig as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
cover_title_config?: {
title?: string
font?: string
size?: number
font_size?: number
color?: string
font_color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: { enabled: boolean; width?: number; color?: string } | boolean
stroke_width?: number
stroke_color?: string
shadow?:
| {
enabled: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
background?: { enabled: boolean; color?: string; padding?: number; radius?: number }
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_height?: number
margin_top?: number
max_chars_per_line?: number
} | null
line_overrides?: unknown[]
}
return {
aiAutoSelect: false,
title: titleConfig.title,
position: titleConfig.position,
@@ -64,24 +116,219 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
color: titleConfig.color,
posX: null,
posY: null,
}),
[titleConfig],
)
lineHeight: c.line_height ?? 1.2,
marginTop: c.margin_top ?? 24,
maxCharsPerLine: c.max_chars_per_line ?? 0,
strokeWidth: c.stroke_width ?? 4,
strokeColor: c.stroke_color ?? "#000000",
shadowOffsetX: c.shadow_offset_x ?? 2,
shadowOffsetY: c.shadow_offset_y ?? 2,
shadowBlur: c.shadow_blur ?? 4,
shadowColor: c.shadow_color ?? "rgba(0,0,0,0.8)",
bgEnabled: !!c.bg_enabled,
bgColor: c.bg_color ?? "rgba(0,0,0,0.5)",
bgPadding: c.bg_padding ?? 12,
bgRadius: c.bg_radius ?? 8,
lineOverrides: Array.isArray(c.line_overrides) ? c.line_overrides : [],
coverTitle: (() => {
const ct = c.cover_title_config as
| null
| (AiAvatarTitleConfig & {
font_size?: number
font_color?: string
stroke?: { enabled?: boolean; width?: number; color?: string } | boolean
stroke_width?: number
stroke_color?: string
shadow?:
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
})
if (!ct) return null
const ctStroke = ct.stroke as
{ enabled?: boolean; width?: number; color?: string } | boolean | undefined
const ctShadow = ct.shadow as
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
| undefined
const ctBg = ct.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
return {
title: ct.title,
font: ct.font,
size: ct.font_size ?? ct.size,
color: ct.font_color ?? ct.color,
bold: ct.bold,
italic: ct.italic,
position: ct.position,
stroke:
typeof ctStroke === "object" && ctStroke ? ctStroke.enabled !== false : !!ctStroke,
strokeWidth:
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
ct.stroke_width ??
4,
strokeColor:
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
ct.stroke_color ??
"#000000",
shadow:
typeof ctShadow === "object" && ctShadow ? ctShadow.enabled !== false : !!ctShadow,
shadowOffsetX:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
ct.shadow_offset_x ??
2,
shadowOffsetY:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
ct.shadow_offset_y ??
2,
shadowBlur:
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
ct.shadow_blur ??
4,
shadowColor:
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
ct.shadow_color ??
"rgba(0,0,0,0.8)",
bgEnabled: ctBg?.enabled ?? !!ct.bg_enabled,
bgColor: ctBg?.color ?? ct.bg_color ?? "rgba(0,0,0,0.5)",
bgPadding: ctBg?.padding ?? ct.bg_padding ?? 12,
bgRadius: ctBg?.radius ?? ct.bg_radius ?? 8,
}
})(),
}
}, [titleConfig])
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
/** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */
const handleApplyPreset = (presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
setActivePreset(presetKey)
const st = preset.style || {}
onUpdate({
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
font: st.font,
size: st.size,
color: st.color,
bold: st.bold,
italic: st.italic,
stroke: st.stroke,
stroke_width: st.stroke_width,
stroke_color: st.stroke_color,
shadow: st.shadow,
shadow_offset_x: st.shadow_offset_x,
shadow_offset_y: st.shadow_offset_y,
shadow_blur: st.shadow_blur,
shadow_color: st.shadow_color,
bg_enabled: st.bg_enabled,
bg_color: st.bg_color,
bg_padding: st.bg_padding,
bg_radius: st.bg_radius,
line_overrides: [],
cover_title_config: null,
})
}
/** 字段 patch 透传:TitleStylePanel 的 onUpdateStylecamelCase → snake_case */
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
const snake: Record<string, unknown> = {}
const map: Record<string, string> = {
lineHeight: "line_height",
marginTop: "margin_top",
maxCharsPerLine: "max_chars_per_line",
strokeWidth: "stroke_width",
strokeColor: "stroke_color",
shadowOffsetX: "shadow_offset_x",
shadowOffsetY: "shadow_offset_y",
shadowBlur: "shadow_blur",
shadowColor: "shadow_color",
bgEnabled: "bg_enabled",
bgColor: "bg_color",
bgPadding: "bg_padding",
bgRadius: "bg_radius",
lineOverrides: "line_overrides",
coverTitle: "cover_title_config",
}
Object.entries(patch).forEach(([k, v]) => {
if (k === "coverTitle" && v && typeof v === "object") {
const ct = v as {
title?: string
font?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: boolean
strokeWidth?: number
strokeColor?: string
shadow?: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineHeight?: number
marginTop?: number
maxCharsPerLine?: number
}
snake.cover_title_config = {
title: ct.title,
font: ct.font,
font_size: ct.size,
font_color: ct.color,
bold: ct.bold,
italic: ct.italic,
position: ct.position,
stroke: ct.stroke
? { enabled: true, width: ct.strokeWidth ?? 4, color: ct.strokeColor ?? "#000" }
: { enabled: false },
shadow: ct.shadow
? {
enabled: true,
offset_x: ct.shadowOffsetX ?? 2,
offset_y: ct.shadowOffsetY ?? 2,
blur: ct.shadowBlur ?? 4,
color: ct.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
background: ct.bgEnabled
? { enabled: true, color: ct.bgColor, padding: ct.bgPadding, radius: ct.bgRadius }
: { enabled: false },
line_height: ct.lineHeight,
margin_top: ct.marginTop,
max_chars_per_line: ct.maxCharsPerLine,
}
} else if (map[k]) {
snake[map[k]] = v
} else {
snake[k] = v
}
})
onUpdate(snake)
}
return (
<div className="aa-title-config">
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
@@ -125,8 +372,13 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
onApplyPreset={handleApplyPreset}
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
activePreset={activePreset}
titlePresets={TITLE_PRESETS}
titlePresets={
TITLE_PRESETS as unknown as React.ComponentProps<typeof TitleStylePanel>["titlePresets"]
}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
@@ -1,18 +1,17 @@
/**
* AI数字人 — 配音库面板(面板3)
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数
*/
import { useEffect, useRef, useState } from "react"
import { message } from "antd"
import { fetchVoices } from "@/api/voices/voices"
import { previewTts } from "@/api/tts"
import { normalizeEmotion } from "../utils/contract"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import type { TtsStyle } from "@/api/tts/styles"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
type VoiceEmotion,
type VoiceLanguage,
VOICE_EMOTION_OPTIONS,
PRESET_VOICE_LANGUAGE_OPTIONS,
CLONE_VOICE_LANGUAGE_OPTIONS,
} from "../types"
@@ -22,8 +21,8 @@ interface PanelVoiceSelectorProps {
onVoiceSourceChange: (source: VoiceSource) => void
selectedVoice: UnifiedVoiceItem | null
onSelectVoice: (voice: UnifiedVoiceItem) => void
emotion: VoiceEmotion
onEmotionChange: (e: VoiceEmotion) => void
style: TtsStyle
onStyleChange: (s: TtsStyle) => void
speed: number
onSpeedChange: (s: number) => void
language: VoiceLanguage
@@ -35,8 +34,8 @@ export function PanelVoiceSelector({
onVoiceSourceChange,
selectedVoice,
onSelectVoice,
emotion,
onEmotionChange,
style,
onStyleChange,
speed,
onSpeedChange,
language,
@@ -139,31 +138,30 @@ export function PanelVoiceSelector({
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
if (voice.type === "clone") {
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}`
const cached = previewCacheRef.current.get(cacheKey)
if (cached) {
playAudioUrl(voice.id, cached)
return
}
const targetId = voice.voice_clone_profile_id || voice.id
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
setPreviewingId(voice.id)
try {
const res = await previewTts({
text: VOICE_PREVIEW_TEXT,
voice_id: targetId,
speed: speed, // 透传用户选择的语速(#1822)
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
style,
})
if (!res.audio_url) {
setPreviewingId(null)
message.error("合成试听失败:未返回音频")
return
}
previewCacheRef.current.set(targetId, res.audio_url)
previewCacheRef.current.set(cacheKey, res.audio_url)
playAudioUrl(voice.id, res.audio_url)
} catch (err) {
setPreviewingId(null)
// DEBUG: 打印详细错误信息
console.error("[AI数字人-克隆试听] previewTts 失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
data: (err as { response?: { data?: unknown } })?.response?.data,
@@ -272,23 +270,6 @@ export function PanelVoiceSelector({
{/* 配音参数 */}
<div className="aa-voice-params">
<div className="aa-voice-params__row">
<div className="aa-voice-params__field">
<label className="aa-label" htmlFor="aa-voice-emotion">
</label>
<select
id="aa-voice-emotion"
className="aa-select"
value={emotion}
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
>
{VOICE_EMOTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="aa-voice-params__field">
<label className="aa-label" htmlFor="aa-voice-language">
@@ -324,6 +305,9 @@ export function PanelVoiceSelector({
onChange={(e) => handleSpeedChange(e.target.value)}
/>
</div>
<div className="aa-voice-params__field">
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
</div>
</div>
</div>
)
@@ -6,7 +6,6 @@ import type { AssetItem } from "@/api/assets"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
type VoiceEmotion,
type VoiceLanguage,
type Script,
type LipsyncJob,
@@ -17,6 +16,7 @@ import {
DEFAULT_TITLE_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
audioUrl: null,
@@ -34,7 +34,7 @@ export function useAiAvatar() {
/* ── 面板2:配音库 ── */
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
const [style, setStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [speed, setSpeed] = useState(1.0)
const [language, setLanguage] = useState<VoiceLanguage>("zh")
@@ -113,8 +113,8 @@ export function useAiAvatar() {
setVoiceSource,
selectedVoice,
setSelectedVoice,
emotion,
setEmotion,
style,
setStyle,
speed,
setSpeed,
language,
+53 -2
View File
@@ -100,7 +100,7 @@ export interface BRollSegment {
pip_scale: number
}
/* ── 标题配置 ── */
/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */
export interface AiAvatarTitleConfig {
title: string
position: string
@@ -115,6 +115,42 @@ export interface AiAvatarTitleConfig {
/** 自定义位置坐标(position=custom 时生效,百分比 0-100 */
pos_x?: number
pos_y?: number
/* ── 排版 ── */
line_height: number
margin_top: number
max_chars_per_line: number
/* ── 描边参数化 ── */
stroke_width: number
stroke_color: string
/* ── 阴影参数化 ── */
shadow_offset_x: number
shadow_offset_y: number
shadow_blur: number
shadow_color: string
/* ── 背景色块 ── */
bg_enabled: boolean
bg_color: string
bg_padding: number
bg_radius: number
/* ── 逐行覆盖 ── */
line_overrides: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
/* ── 封面独立标题(null=沿用主标题) ── */
cover_title_config: null | Partial<AiAvatarTitleConfig>
}
/* ── 封面配置 ── */
@@ -149,12 +185,27 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
size: 48,
bold: true,
italic: false,
stroke: false,
stroke: true,
shadow: false,
color: "#ffffff",
auto_subtitle: true,
pos_x: undefined,
pos_y: undefined,
line_height: 1.2,
margin_top: 24,
max_chars_per_line: 0,
stroke_width: 4,
stroke_color: "#000000",
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
bg_color: "rgba(0,0,0,0.5)",
bg_padding: 12,
bg_radius: 8,
line_overrides: [],
cover_title_config: null,
}
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
+86 -3
View File
@@ -67,6 +67,37 @@ export function buildTitleConfigPayload(
const text = (cfg.title || "").trim()
if (!text) return {}
const position = cfg.position || "bottom"
const anyCfg = cfg as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_overrides?: unknown[]
cover_title_config?: Record<string, unknown> | null
}
const strokeWidth = anyCfg.stroke_width != null ? anyCfg.stroke_width : 4
const strokeColor = anyCfg.stroke_color || "#000000"
const shadowOffsetX = anyCfg.shadow_offset_x != null ? anyCfg.shadow_offset_x : 2
const shadowOffsetY = anyCfg.shadow_offset_y != null ? anyCfg.shadow_offset_y : 2
const shadowBlur = anyCfg.shadow_blur != null ? anyCfg.shadow_blur : 4
const shadowColor = anyCfg.shadow_color || "rgba(0,0,0,0.8)"
const lineHeight = anyCfg.line_height != null ? anyCfg.line_height : 1.2
const marginTop = anyCfg.margin_top != null ? anyCfg.margin_top : 24
const maxCharsPerLine = anyCfg.max_chars_per_line ?? 0
const bgEnabled = !!anyCfg.bg_enabled
const bgColor = anyCfg.bg_color || "rgba(0,0,0,0.5)"
const bgPadding = anyCfg.bg_padding != null ? anyCfg.bg_padding : 12
const bgRadius = anyCfg.bg_radius != null ? anyCfg.bg_radius : 8
const payload: Record<string, unknown> = {
text,
enabled: true,
@@ -75,16 +106,68 @@ export function buildTitleConfigPayload(
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
shadow: cfg.shadow
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
italic: !!cfg.italic,
stroke: cfg.stroke
? { enabled: true, width: strokeWidth, color: strokeColor }
: { enabled: false },
shadow: cfg.shadow
? {
enabled: true,
color: shadowColor,
offset_x: shadowOffsetX,
offset_y: shadowOffsetY,
blur: shadowBlur,
}
: { enabled: false },
line_height: lineHeight,
margin_top: marginTop,
max_chars_per_line: maxCharsPerLine,
background: bgEnabled
? { enabled: true, color: bgColor, padding: bgPadding, radius: bgRadius }
: { enabled: false },
line_overrides: Array.isArray(anyCfg.line_overrides) ? anyCfg.line_overrides : [],
}
// 自定义坐标(custom 位置)
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
payload.pos_x = cfg.pos_x
payload.pos_y = cfg.pos_y
}
// 封面独立标题配置
if (anyCfg.cover_title_config) {
const ctc = anyCfg.cover_title_config
payload.cover_title_config = {
title: ctc.title,
font: ctc.font,
font_size: ctc.size,
font_color: ctc.color,
position: ctc.position,
bold: ctc.bold,
italic: ctc.italic,
stroke: ctc.stroke
? { enabled: true, width: ctc.stroke_width ?? 4, color: ctc.stroke_color ?? "#000000" }
: { enabled: false },
shadow: ctc.shadow
? {
enabled: true,
color: ctc.shadow_color ?? shadowColor,
offset_x: ctc.shadow_offset_x ?? 2,
offset_y: ctc.shadow_offset_y ?? 2,
blur: ctc.shadow_blur ?? 4,
}
: { enabled: false },
line_height: ctc.line_height ?? lineHeight,
margin_top: ctc.margin_top ?? marginTop,
max_chars_per_line: ctc.max_chars_per_line ?? maxCharsPerLine,
background: ctc.bg_enabled
? {
enabled: true,
color: ctc.bg_color ?? bgColor,
padding: ctc.bg_padding ?? bgPadding,
radius: ctc.bg_radius ?? bgRadius,
}
: { enabled: false },
}
}
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
if (titleImageDataUrl) {
payload.title_image_dataurl = titleImageDataUrl
+211 -76
View File
@@ -9,37 +9,78 @@
* 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale,
* 保证 1080p / 4K 成片里标题视觉大小与预览一致。
*/
import { getFontFamily } from "@/components/title/constants"
import type { AiAvatarTitleConfig } from "../types"
export interface RenderTitlePngOptions {
/** 标题配置 */
titleConfig: AiAvatarTitleConfig
/** 视频宽度(像素),默认 720 */
videoWidth?: number
/** 视频高度(像素),默认 1280 */
videoHeight?: number
useCoverTitle?: boolean
}
/**
* 将标题渲染为透明背景 PNG 的 dataURLdata:image/png;base64,...
* Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。
*
* 标题为空时返回 null。
*/
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts
if (!titleConfig) return null
const rawTitle = (titleConfig.title || "").trim()
if (!rawTitle) return null
// 按 / 或 分割为多行
const lines = rawTitle
function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] {
const manual = rawTitle
.split(/[/]/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
if (!maxCharsPerLine || maxCharsPerLine <= 0) return manual
const out: string[] = []
manual.forEach((seg) => {
for (let i = 0; i < seg.length; i += maxCharsPerLine) {
out.push(seg.slice(i, i + maxCharsPerLine))
}
})
return out
}
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280, useCoverTitle } = opts
if (!titleConfig) return null
type TitleCfgExt = AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_overrides?: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
cover_title_config?: Partial<AiAvatarTitleConfig> | null
pos_x?: number
pos_y?: number
}
const cfg: TitleCfgExt =
useCoverTitle && titleConfig.cover_title_config
? ({
...(titleConfig as TitleCfgExt),
...(titleConfig.cover_title_config as object),
} as TitleCfgExt)
: (titleConfig as TitleCfgExt)
const rawTitle = (cfg.title || "").trim()
if (!rawTitle) return null
const maxCharsPerLine = cfg.max_chars_per_line ?? 0
const lines = autoWrapLines(rawTitle, maxCharsPerLine)
if (lines.length === 0) return null
// 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale
const scale = videoWidth / 720
const r = (v: number) => Math.round(v * scale)
@@ -49,86 +90,183 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
const ctx = canvas.getContext("2d")
if (!ctx) return null
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
const baseSize = Math.max(12, Math.round(cfg.size || 48))
const size = r(baseSize)
const bold = !!titleConfig.bold
const italic = !!titleConfig.italic
const color = titleConfig.color || "#ffffff"
const stroke = !!titleConfig.stroke
const shadow = !!titleConfig.shadow
const bold = !!cfg.bold
const italic = !!cfg.italic
const color = cfg.color || "#ffffff"
const stroke = !!cfg.stroke
const shadow = !!cfg.shadow
// 字体族 fallback 链:优先中文字体
const fontFamily =
'"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif'
const fontParts: string[] = []
if (italic) fontParts.push("italic")
if (bold) fontParts.push("bold")
fontParts.push(`${size}px`, fontFamily)
ctx.font = fontParts.join(" ")
const strokeWidthBase = cfg.stroke_width != null ? cfg.stroke_width : 4
const strokeColor = cfg.stroke_color || "#000000"
const shadowOffsetXBase = cfg.shadow_offset_x != null ? cfg.shadow_offset_x : 2
const shadowOffsetYBase = cfg.shadow_offset_y != null ? cfg.shadow_offset_y : 2
const shadowBlurBase = cfg.shadow_blur != null ? cfg.shadow_blur : 4
const shadowColor = cfg.shadow_color || "rgba(0,0,0,0.8)"
const lineHeightScale = cfg.line_height != null ? cfg.line_height : 1.2
const marginTopBase = cfg.margin_top != null ? cfg.margin_top : 24
const bgEnabled = !!cfg.bg_enabled
const bgColor = cfg.bg_color || "rgba(0,0,0,0.5)"
const bgPaddingBase = cfg.bg_padding != null ? cfg.bg_padding : 12
const bgRadiusBase = cfg.bg_radius != null ? cfg.bg_radius : 8
const fontKey = cfg.font || "思源黑体"
const fontFamily = getFontFamily(fontKey)
const setFont = (sz: number, bd: boolean, it: boolean) => {
const parts: string[] = []
if (it) parts.push("italic")
if (bd) parts.push("bold")
parts.push(`${sz}px`, fontFamily)
ctx.font = parts.join(" ")
}
setFont(size, bold, italic)
ctx.fillStyle = color
ctx.textAlign = "center"
ctx.textBaseline = "middle"
// 阴影(shadow=true 时开启)——按 scale 缩放
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = r(2)
}
const lineGap = size * lineHeightScale
const totalTextH = lines.length * lineGap - (lineGap - size)
let maxLineW = 0
lines.forEach((l: string) => {
const m = ctx.measureText(l).width
if (m > maxLineW) maxLineW = m
})
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD
const PAD = r(16)
let centerX = videoWidth / 2
const position = titleConfig.position || "bottom"
const lineGap = size * 1.2
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
// 文本块顶部 ytextBaseline=middle 时首行基线)
const position = cfg.position || "bottom"
let firstLineY: number
if (
position === "custom" &&
typeof titleConfig.pos_x === "number" &&
typeof titleConfig.pos_y === "number"
) {
centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
centerX = (Math.max(0, Math.min(100, cfg.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, cfg.pos_y)) / 100) * videoHeight
firstLineY = centerY - totalTextH / 2 + size / 2
} else if (position === "top") {
// 顶部:y = size/2 + PAD
firstLineY = size / 2 + PAD
firstLineY = size / 2 + PAD + r(marginTopBase)
} else if (position === "center") {
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
} else {
// bottom(默认)
firstLineY = videoHeight - totalTextH - PAD + size / 2
}
// 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p
const doStroke = stroke
const strokeWidth = Math.max(r(2), Math.round(size * 0.06))
// 逐行绘制
lines.forEach((line, idx) => {
if (shadow) {
ctx.shadowColor = shadowColor
ctx.shadowBlur = r(shadowBlurBase)
ctx.shadowOffsetX = r(shadowOffsetXBase)
ctx.shadowOffsetY = r(shadowOffsetYBase)
} else {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const bgPad = r(bgPaddingBase)
const bgR = r(bgRadiusBase)
const bgW = maxLineW + bgPad * 2
const bgH = totalTextH + bgPad * 2
const bgX = centerX - bgW / 2
const bgY = firstLineY - size / 2 - bgPad
if (bgEnabled) {
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.fillStyle = bgColor
if (
bgR > 0 &&
(
ctx as CanvasRenderingContext2D & {
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
).roundRect
) {
;(
ctx as CanvasRenderingContext2D & {
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
).roundRect(bgX, bgY, bgW, bgH, bgR)
ctx.fill()
} else {
ctx.fillRect(bgX, bgY, bgW, bgH)
}
ctx.restore()
}
const sw = stroke ? Math.max(r(1), r(strokeWidthBase)) : 0
const lineOverrides = cfg.line_overrides || []
lines.forEach((line: string, idx: number) => {
const y = firstLineY + idx * lineGap
if (doStroke) {
const prevShadowColor = ctx.shadowColor
const prevShadowBlur = ctx.shadowBlur
// 描边不要带阴影(避免黑色描边发虚)
const override = lineOverrides.find((lo) => lo.line_index === idx)
const lineSize = override?.size ? r(Math.max(12, Math.round(override.size))) : size
const lineColor = override?.color || color
const lineBold = override?.bold != null ? !!override.bold : bold
const lineItalic = override?.italic != null ? !!override.italic : italic
const lineStroke = override?.stroke != null ? !!override.stroke : stroke
setFont(lineSize, lineBold, lineItalic)
ctx.fillStyle = lineColor
if (shadow) {
ctx.shadowColor = shadowColor
ctx.shadowBlur = r(shadowBlurBase)
ctx.shadowOffsetX = r(shadowOffsetXBase)
ctx.shadowOffsetY = r(shadowOffsetYBase)
} else {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = strokeWidth
ctx.strokeStyle = "#000000"
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const lineSw = override?.size
? Math.max(r(1), Math.round(lineSize * (strokeWidthBase / baseSize)))
: sw
if (lineStroke && lineSw > 0) {
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.lineWidth = lineSw
ctx.strokeStyle = strokeColor
ctx.lineJoin = "round"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
} else {
ctx.shadowColor = prevShadowColor
ctx.shadowBlur = prevShadowBlur
}
ctx.restore()
}
ctx.fillText(line, centerX, y)
if (override?.highlights?.length) {
const fullW = ctx.measureText(line).width
const charW = line.length > 0 ? fullW / line.length : lineSize
override.highlights.forEach((hl) => {
if (!hl.word) return
const pos = line.indexOf(hl.word)
if (pos < 0) return
const hlX = centerX - fullW / 2 + pos * charW + (charW * hl.word.length) / 2
const hlColor = hl.color || "#ffd700"
const hlScale = hl.scale || 1
const hlSize = lineSize * hlScale
const hlBold = hl.bold != null ? !!hl.bold : true
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
setFont(hlSize, hlBold, lineItalic)
ctx.fillStyle = hlColor
if (lineStroke && lineSw > 0) {
ctx.lineWidth = Math.max(r(1), Math.round(hlSize * (strokeWidthBase / baseSize)))
ctx.strokeStyle = strokeColor
ctx.lineJoin = "round"
ctx.strokeText(hl.word, hlX, y)
}
ctx.fillText(hl.word, hlX, y)
ctx.restore()
})
}
})
try {
@@ -138,9 +276,6 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
}
}
/**
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
*/
export function getVideoResolution(
videoUrl: string,
timeoutMs = 3000,
+43 -27
View File
@@ -33,6 +33,7 @@ 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"
import "./generate.css"
import "./generate-points.css"
@@ -76,6 +77,8 @@ const GeneratePage: React.FC = () => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -328,6 +331,7 @@ const GeneratePage: React.FC = () => {
selectedScript,
ttsVoiceId,
ttsVoiceSource,
ttsStyle,
ttsVoiceAssetId,
dedupEnabled,
style,
@@ -409,9 +413,15 @@ const GeneratePage: React.FC = () => {
)
const handleTtsSynthesized = useCallback(
(payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => {
(payload: {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
ttsStyle?: string
}) => {
setTtsVoiceId(payload.ttsVoiceId)
setTtsVoiceSource(payload.ttsVoiceSource)
if (payload.ttsStyle) setTtsStyle(payload.ttsStyle)
setTtsVoiceAssetId(payload.voiceAssetId)
if (payload.ttsVoiceSource === "clone") {
setSelectedClonedVoice(payload.ttsVoiceId)
@@ -427,6 +437,7 @@ const GeneratePage: React.FC = () => {
[
setTtsVoiceId,
setTtsVoiceSource,
setTtsStyle,
setTtsVoiceAssetId,
setSelectedVoice,
setSelectedClonedVoice,
@@ -437,19 +448,22 @@ const GeneratePage: React.FC = () => {
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
if (ENABLE_CREDIT_SYSTEM) {
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
}
}
if (isBatch) {
if (selectedVariantIds.length === 0) {
@@ -520,19 +534,18 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(
() =>
hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
),
[unitsForCost, balance, dailyUsage, rules],
)
const insufficientPoints = !pointsEstimate.sufficient
const pointsEstimate = useMemo(() => {
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
return hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
}, [unitsForCost, balance, dailyUsage, rules])
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
/* ================================================================
渲染
@@ -627,6 +640,7 @@ const GeneratePage: React.FC = () => {
onToggleStroke={styleUpdaters.toggleStroke}
onToggleShadow={styleUpdaters.toggleShadow}
onApplyPreset={styleUpdaters.applyPreset}
onUpdateStyle={styleUpdaters.updateStyle}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
bgm={bgm}
@@ -788,6 +802,8 @@ const GeneratePage: React.FC = () => {
open={ttsModalOpen}
scriptText={selectedScript?.content ?? ""}
scriptTitle={selectedScript?.title ?? ""}
style={ttsStyle}
onStyleChange={setTtsStyle}
onCancel={() => setTtsModalOpen(false)}
onSynthesized={handleTtsSynthesized}
/>
@@ -51,8 +51,14 @@ export interface GenerateStepContentProps {
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
titlePresets: Array<{
key: string
label: string
emoji?: string
style: Record<string, unknown>
}>
/* ── 封面 ── */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
@@ -119,6 +125,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
titlePresets,
coverSettings,
@@ -191,6 +198,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
previewCount={previewCount}
@@ -12,7 +12,8 @@ import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
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"
@@ -33,8 +34,15 @@ interface Step4TitleSettingsProps {
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
titlePresets: Array<{
key: string
label: string
emoji?: string
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
}>
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
@@ -84,6 +92,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
titlePresets,
previewCount = 1,
@@ -285,6 +294,8 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
showCoverToggle
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
@@ -19,6 +19,8 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import { VOICE_GENDER_ICON } from "../constants"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
interface TtsVoiceModalProps {
open: boolean
@@ -31,7 +33,11 @@ interface TtsVoiceModalProps {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
ttsStyle: TtsStyle
}) => void
/** 当前风格 */
style?: TtsStyle
onStyleChange?: (s: TtsStyle) => void
}
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
@@ -42,7 +48,15 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
scriptTitle,
onCancel,
onSynthesized,
style: externalStyle,
onStyleChange,
}) => {
const [internalStyle, setInternalStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const currentStyle: TtsStyle = externalStyle ?? internalStyle
const handleStyleChange = (s: TtsStyle) => {
setInternalStyle(s)
onStyleChange?.(s)
}
const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset")
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
const [status, setStatus] = useState<TtsSynthStatus>("idle")
@@ -77,6 +91,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
setStatus("idle")
setError(null)
setActiveTab("preset")
setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE)
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
@@ -91,6 +106,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handlePreview = useCallback(
@@ -143,6 +159,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
text: textToSynth,
speed: 1.0,
language: "zh-CN",
style: currentStyle,
}
if (isClone) {
payload.voice_clone_profile_id = selectedVoiceId
@@ -187,13 +204,14 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
voiceAssetId: jobId,
ttsVoiceId: selectedVoiceId,
ttsVoiceSource: isClone ? "clone" : "preset",
ttsStyle: currentStyle,
})
} catch (err: unknown) {
setStatus("error")
const msg = err instanceof Error ? err.message : "合成失败,请稍后重试"
setError(msg)
}
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized])
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized, currentStyle])
const renderVoiceCard = (v: {
id: string
@@ -393,6 +411,10 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
{textToSynth.length}
</div>
<div style={{ marginBottom: 12 }}>
<TtsStyleSelector value={currentStyle} onChange={handleStyleChange} compact />
</div>
<Tabs
activeKey={activeTab}
onChange={(k) => {
@@ -0,0 +1,221 @@
/**
* 标题迷你 Canvas 预览(#2001
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "../../constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底) */
background?: string
/** 高度(可选,默认 width/2 */
height?: number
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[/\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width / 1.8)
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
const scale = width / 360
const r = (v: number) => Math.round(v * scale)
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
startY = h - totalH - r(16) + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}, [settings, width, h, text])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -190,3 +190,255 @@
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
============================================================ */
.ts-panel {
position: relative;
}
/* 预览 */
.ts-preview-wrap {
margin-bottom: 14px;
display: flex;
justify-content: center;
padding: 10px;
background: #0f172a;
border-radius: 8px;
}
/* 表单字段 */
.ts-form-field {
margin-bottom: 12px;
}
.ts-form-field label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary, #1f2937);
}
.ts-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ts-field-value {
font-size: 12px;
font-weight: 600;
color: var(--primary-color, #7c3aed);
}
.ts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ts-half {
margin-bottom: 0;
}
.ts-select {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
background: var(--bg-primary, #fff);
padding: 0 10px;
font-size: 13px;
outline: 0;
color: var(--text-primary, #1f2937);
}
.ts-select:focus {
border-color: var(--primary-color, #7c3aed);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
}
.ts-input {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: 0;
}
.ts-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #e5e7eb;
border-radius: 2px;
outline: none;
}
.ts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
}
/* 样式按钮 B/I/S/☁ */
.ts-style-btns {
display: flex;
gap: 6px;
}
.ts-style-btn {
width: 34px;
height: 34px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: 0.15s;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ts-style-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-style-btn.active {
background: #faf5ff;
color: #6d28d9;
border-color: #7c3aed;
font-weight: 700;
}
/* 色板 */
.ts-color-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ts-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e5e7eb;
cursor: pointer;
padding: 0;
transition: 0.15s;
}
.ts-color-swatch:hover {
transform: scale(1.1);
}
.ts-color-swatch.active {
box-shadow: 0 0 0 2px #7c3aed;
transform: scale(1.1);
}
.ts-color-custom {
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
color: #666;
font-size: 14px;
line-height: 20px;
}
.ts-color-native {
width: 0;
height: 0;
border: 0;
padding: 0;
}
/* 预设网格 10个 - 5列 */
.ts-presets-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.ts-preset-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
padding: 4px;
cursor: pointer;
transition: 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.ts-preset-card:hover {
border-color: #7c3aed;
}
.ts-preset-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
.ts-preset-preview {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #0f172a;
}
.ts-preset-preview canvas {
max-width: 100%;
max-height: 100%;
}
.ts-preset-meta {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #4b5563;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px 2px;
}
.ts-preset-emoji {
font-size: 11px;
}
.ts-preset-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ts-toggle-row label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 10px;
}
.ts-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #7c3aed;
}
/* Tabs 紧凑样式 */
.xx-title-style-section .ant-tabs-nav {
margin-bottom: 10px;
}
.xx-title-style-section .ant-tabs-tab {
font-size: 12px !important;
padding: 6px 8px !important;
}
@@ -1,12 +1,26 @@
/**
* 标题样式设置
* 位置/字体/字号/样式按钮/预设
* 标题样式设置面板(#2001 升级)
*
* P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 /
* 4款爆款字体 / 抖音爆款黄预设
* P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 /
* 封面独立标题配置入口
*
* 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/
* onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。
*/
import React from "react"
import React, { useState } from "react"
import { Tabs } from "antd"
import type { TitleSettings } from "../../types"
import TitlePresetsGrid from "./TitlePresetsGrid"
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
import {
FONT_OPTIONS as NEW_FONT_OPTIONS,
TITLE_PRESETS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "@/components/title/constants"
import TitleMiniPreview from "./TitleMiniPreview"
import "./TitleStylePanel.css"
interface PositionOption {
@@ -14,14 +28,17 @@ interface PositionOption {
label: string
}
interface TitlePresetItem {
interface LegacyPreset {
key: string
label: string
previewStyle: React.CSSProperties
emoji?: string
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
}
interface TitleStylePanelProps {
settings: TitleSettings
/* 旧 props(兼容) */
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
@@ -31,9 +48,130 @@ interface TitleStylePanelProps {
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: TitlePresetItem[]
titlePresets: LegacyPreset[]
POSITION_OPTIONS: PositionOption[]
FONT_OPTIONS: string[]
FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }>
/* 新增:统一字段更新 */
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
/* 是否显示封面独立标题切换 */
showCoverToggle?: boolean
/** 画布预览宽度(默认 200) */
previewWidth?: number
}
/* ── 通用 Slider + Label 行 ── */
const SliderRow: React.FC<{
label: string
value: number
min: number
max: number
step?: number
unit?: string
onChange: (v: number) => void
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 + 自定义颜色选择 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => onChange(e.target.value)}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: 0,
height: 0,
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
/* ── 预设网格(含爆款黄,10 个 + 迷你 Canvas 缩略) ── */
const PresetGrid: React.FC<{
activePreset: string | null
onApply: (key: string) => void
settings: TitleSettings
}> = ({ activePreset, onApply, settings }) => {
return (
<div className="ts-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = activePreset === p.key
// 合并当前 style 与 preset.style 用于预览(仅预览时覆盖)
const previewStyle: TitleSettings = { ...settings, ...(p.style as Partial<TitleSettings>) }
return (
<button
key={p.key}
type="button"
className={`ts-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)}
title={p.label}
>
<div className="ts-preset-preview">
<TitleMiniPreview settings={previewStyle} width={100} sampleText="标题" />
</div>
<div className="ts-preset-meta">
<span className="ts-preset-emoji">{p.emoji}</span>
<span className="ts-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
)
}
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
@@ -47,107 +185,352 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets,
titlePresets: _titlePresets,
POSITION_OPTIONS,
FONT_OPTIONS,
showCoverToggle = false,
previewWidth = 220,
onUpdateStyle,
}) => {
const upd = (patch: Partial<TitleSettings>) => {
onUpdateStyle?.(patch)
}
/* 封面独立标题切换 */
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
return (
<div className="xx-title-style-section">
<h4 className="xx-section-subtitle"></h4>
{/* 位置 + 字体 一行 */}
<div className="xx-title-style-row">
<div className="xx-form-field xx-half-field">
<label></label>
<select
className="xx-form-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="xx-form-field xx-half-field">
<label></label>
<select
className="xx-form-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
{/* 字号滑块 */}
<div className="xx-form-field">
<div className="xx-field-label-row">
<label></label>
<span className="xx-field-value">{settings.size}px</span>
</div>
<input
className="xx-slider"
type="range"
min={12}
max={128}
value={settings.size}
onChange={(e) => onUpdateSize(Number(e.target.value))}
<div className="xx-title-style-section ts-panel">
{/* 实时迷你预览 */}
<div className="ts-preview-wrap">
<TitleMiniPreview
settings={settings}
width={previewWidth}
sampleText={settings.title || "预览标题文字"}
/>
</div>
{/* 预设样式 */}
<div className="xx-form-field">
<label></label>
<TitlePresetsGrid
presets={titlePresets}
activePreset={activePreset}
onApply={onApplyPreset}
fontFamily={settings.font}
/>
{/* 预设样式10个,含抖音爆款黄) */}
<div className="ts-form-field">
<label></label>
<PresetGrid activePreset={activePreset} onApply={onApplyPreset} settings={settings} />
</div>
{/* 样式按钮:粗体/斜体/描边/阴影 */}
<div className="xx-form-field">
<label></label>
<div className="xx-style-btns">
<button
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
onClick={onToggleBold}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
onClick={onToggleItalic}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
onClick={onToggleStroke}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
onClick={onToggleShadow}
title="阴影"
>
</button>
</div>
</div>
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
{/* 位置 + 字体 */}
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label></label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label></label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{NEW_FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
{/* 样式按钮 */}
<div className="ts-form-field">
<label></label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
// 如果之前 strokeWidth 为 0,启用时给个默认值
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2) {
upd({ strokeWidth: 4 })
}
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
</button>
</div>
</div>
{/* 字色 */}
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label></label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = /
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverOpen}
onChange={(e) => {
setCoverOpen(e.target.checked)
if (!e.target.checked) {
upd({ coverTitle: null })
} else {
upd({
coverTitle: {
font: settings.font,
size: Math.round(settings.size * 0.9),
color: settings.color,
bold: settings.bold,
},
})
}
}}
/>
使
</label>
</div>
{coverOpen && settings.coverTitle && (
<div style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6 }}>
//
</div>
)}
</>
),
},
]
: []),
]}
/>
</div>
)
}
+19 -2
View File
@@ -54,11 +54,28 @@ export const POSITION_OPTIONS = [
{ value: "custom", label: "自定义" },
]
/* ── 标题字体选项 ── */
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
/* ── 标题字体选项#2001:新增 4 款爆款字体) ── */
export const FONT_OPTIONS = [
"优设标题黑",
"阿里普惠体Bold",
"抖音美好体",
"思源黑体Heavy",
"思源黑体",
"思源宋体",
"苹方",
"微软雅黑",
"楷体",
]
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
export const FONT_FAMILY_MAP: Record<string, string> = {
:
'"YouSheBiaoTiHei","YouShe Title Black","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
Bold:
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
: '"Douyin Sans","DouyinSansBold","Source Han Sans SC Heavy","PingFang SC",sans-serif',
Heavy:
'"Source Han Sans SC Heavy","Noto Sans SC Heavy","Source Han Sans CN Heavy","PingFang SC",sans-serif',
: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif',
: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
@@ -22,6 +22,8 @@ export interface UseGenerateVideoProps {
ttsVoiceId?: string
/** TTS 音色来源 */
ttsVoiceSource?: "preset" | "clone"
/** TTS 配音风格 */
ttsStyle?: string
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
ttsVoiceAssetId?: string
/** 智能降重开关(默认 true) */
@@ -30,8 +30,23 @@ interface UseBatchCoversOptions {
color: string
position: string
bold: boolean
italic?: boolean
stroke: boolean
strokeWidth?: number
strokeColor?: string
shadow: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
lineHeight?: number
marginTop?: number
maxCharsPerLine?: number
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineOverrides?: unknown[]
}
covers: string[]
onCoversChange: CoversChangeFn
@@ -100,8 +115,37 @@ export function useBatchCovers({
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
stroke: titleStyle.stroke,
shadow: titleStyle.shadow,
italic: titleStyle.italic,
stroke: titleStyle.stroke
? {
enabled: true,
width: titleStyle.strokeWidth ?? 4,
color: titleStyle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: titleStyle.shadow
? {
enabled: true,
offset_x: titleStyle.shadowOffsetX ?? 2,
offset_y: titleStyle.shadowOffsetY ?? 2,
blur: titleStyle.shadowBlur ?? 4,
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: titleStyle.lineHeight ?? 1.2,
margin_top: titleStyle.marginTop ?? 24,
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
background: titleStyle.bgEnabled
? {
enabled: true,
color: titleStyle.bgColor,
padding: titleStyle.bgPadding,
radius: titleStyle.bgRadius,
}
: { enabled: false },
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
Record<string, unknown>
>,
},
}
: {}),
@@ -13,6 +13,7 @@ import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../../types/cover"
import type { PresetVoiceItem } from "@/api/voices"
import type { ScriptItem } from "@/api/scripts"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants"
import type { TitleSettings } from "../../types"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
@@ -33,6 +34,21 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 0,
strokeWidth: 4,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
coverTitle: null,
}
export interface GenerateFormState {
@@ -95,6 +111,9 @@ export interface GenerateFormState {
/** TTS 音色来源:preset 系统 / clone 克隆 */
ttsVoiceSource: "preset" | "clone"
setTtsVoiceSource: (src: "preset" | "clone") => void
/** TTS 配音风格 */
ttsStyle: TtsStyle
setTtsStyle: (s: TtsStyle) => void
/** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice */
ttsVoiceAssetId: string
setTtsVoiceAssetId: (id: string) => void
@@ -234,6 +253,7 @@ export const useGenerateFormState = (): GenerateFormState => {
const [selectedScript, setSelectedScript] = useState<ScriptItem | null>(null)
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset")
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState<string>("")
const [dedupEnabled, setDedupEnabled] = useState<boolean>(true)
@@ -311,6 +331,8 @@ export const useGenerateFormState = (): GenerateFormState => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -1,6 +1,7 @@
import { useEffect } from "react"
import type { CoverConfig } from "../../types/cover"
import type { TitleSettings } from "../../types"
import type { TitleLineOverride } from "@/components/title/types"
import type { TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
@@ -12,6 +13,156 @@ interface UsePlanConfigLoaderOptions {
setSelectedMaterials: (ids: string[]) => void
}
/** #2001:统一归一化 title_config snake_case -> camelCase TitleSettings */
function mapTitleCfgToSettings(
prev: TitleSettings,
tc: TitleConfig & Record<string, unknown>,
): TitleSettings {
const stroke = tc.stroke as
boolean | { enabled?: boolean; width?: number; color?: string } | undefined
const strokeEnabled: boolean | undefined =
typeof stroke === "object" && stroke ? stroke.enabled !== false : !!stroke || undefined
const strokeW: number | undefined =
typeof stroke === "object" && stroke
? (stroke.width ?? (tc.stroke_width as number | undefined))
: (tc.stroke_width as number | undefined)
const strokeC: string | undefined =
typeof stroke === "object" && stroke
? (stroke.color ?? (tc.stroke_color as string | undefined))
: (tc.stroke_color as string | undefined)
const shadow = tc.shadow as
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
| undefined
const shadowEnabled: boolean | undefined =
typeof shadow === "object" && shadow ? shadow.enabled !== false : !!shadow || undefined
const shOffX: number | undefined =
typeof shadow === "object" && shadow
? (shadow.offset_x ?? (tc.shadow_offset_x as number | undefined))
: (tc.shadow_offset_x as number | undefined)
const shOffY: number | undefined =
typeof shadow === "object" && shadow
? (shadow.offset_y ?? (tc.shadow_offset_y as number | undefined))
: (tc.shadow_offset_y as number | undefined)
const shBlur: number | undefined =
typeof shadow === "object" && shadow
? (shadow.blur ?? (tc.shadow_blur as number | undefined))
: (tc.shadow_blur as number | undefined)
const shColor: string | undefined =
typeof shadow === "object" && shadow
? (shadow.color ?? (tc.shadow_color as string | undefined))
: (tc.shadow_color as string | undefined)
const bg = tc.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
const bgEnabled: boolean | undefined =
(bg && typeof bg === "object" ? bg.enabled : undefined) ??
(tc.bg_enabled as boolean | undefined)
const bgColor: string | undefined =
(bg && typeof bg === "object" ? bg.color : undefined) ?? (tc.bg_color as string | undefined)
const bgPadding: number | undefined =
(bg && typeof bg === "object" ? bg.padding : undefined) ?? (tc.bg_padding as number | undefined)
const bgRadius: number | undefined =
(bg && typeof bg === "object" ? bg.radius : undefined) ?? (tc.bg_radius as number | undefined)
const ct = (tc.cover_title_config ?? null) as null | Record<string, unknown>
let coverTitle: TitleSettings["coverTitle"] = prev.coverTitle
if (ct) {
const ctStroke = ct.stroke as
boolean | { enabled?: boolean; width?: number; color?: string } | undefined
const ctShadow = ct.shadow as
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
| undefined
const ctBg = ct.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
coverTitle = {
title: (ct.title as string | undefined) ?? prev.coverTitle?.title ?? "",
font: (ct.font as string | undefined) ?? prev.coverTitle?.font,
size:
(ct.font_size as number | undefined) ??
(ct.size as number | undefined) ??
prev.coverTitle?.size,
color:
(ct.font_color as string | undefined) ??
(ct.color as string | undefined) ??
prev.coverTitle?.color,
bold: (ct.bold as boolean | undefined) ?? prev.coverTitle?.bold,
italic: (ct.italic as boolean | undefined) ?? prev.coverTitle?.italic,
position: (ct.position as string | undefined) ?? prev.coverTitle?.position,
stroke:
typeof ctStroke === "object" && ctStroke
? ctStroke.enabled !== false
: ((ctStroke as boolean | undefined) ?? prev.coverTitle?.stroke),
strokeWidth:
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
(ct.stroke_width as number | undefined) ??
prev.coverTitle?.strokeWidth,
strokeColor:
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
(ct.stroke_color as string | undefined) ??
prev.coverTitle?.strokeColor,
shadow:
typeof ctShadow === "object" && ctShadow
? ctShadow.enabled !== false
: ((ctShadow as boolean | undefined) ?? prev.coverTitle?.shadow),
shadowOffsetX:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
(ct.shadow_offset_x as number | undefined) ??
prev.coverTitle?.shadowOffsetX,
shadowOffsetY:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
(ct.shadow_offset_y as number | undefined) ??
prev.coverTitle?.shadowOffsetY,
shadowBlur:
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
(ct.shadow_blur as number | undefined) ??
prev.coverTitle?.shadowBlur,
shadowColor:
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
(ct.shadow_color as string | undefined) ??
prev.coverTitle?.shadowColor,
bgEnabled:
ctBg?.enabled ?? (ct.bg_enabled as boolean | undefined) ?? prev.coverTitle?.bgEnabled,
bgColor: ctBg?.color ?? (ct.bg_color as string | undefined) ?? prev.coverTitle?.bgColor,
bgPadding:
ctBg?.padding ?? (ct.bg_padding as number | undefined) ?? prev.coverTitle?.bgPadding,
bgRadius: ctBg?.radius ?? (ct.bg_radius as number | undefined) ?? prev.coverTitle?.bgRadius,
}
}
const result: TitleSettings = {
...prev,
title: (tc.content as string | undefined) || prev.title,
aiAutoSelect: (tc.ai_auto_select as boolean | undefined) || false,
position: prev.position,
font: (tc.font_preset as string | undefined) || prev.font,
size: (tc.font_size as number | undefined) || prev.size,
color: (tc.font_color as string | undefined) || prev.color,
bold: (tc.bold as boolean | undefined) ?? prev.bold,
italic: (tc.italic as boolean | undefined) ?? prev.italic,
stroke: strokeEnabled ?? prev.stroke,
strokeWidth: strokeW ?? prev.strokeWidth,
strokeColor: strokeC ?? prev.strokeColor,
shadow: shadowEnabled ?? prev.shadow,
shadowOffsetX: shOffX ?? prev.shadowOffsetX,
shadowOffsetY: shOffY ?? prev.shadowOffsetY,
shadowBlur: shBlur ?? prev.shadowBlur,
shadowColor: shColor ?? prev.shadowColor,
lineHeight: (tc.line_height as number | undefined) ?? prev.lineHeight,
marginTop: (tc.margin_top as number | undefined) ?? prev.marginTop,
maxCharsPerLine: (tc.max_chars_per_line as number | undefined) ?? prev.maxCharsPerLine,
bgEnabled: bgEnabled ?? prev.bgEnabled,
bgColor: bgColor ?? prev.bgColor,
bgPadding: bgPadding ?? prev.bgPadding,
bgRadius: bgRadius ?? prev.bgRadius,
lineOverrides: ((tc.line_overrides as unknown[] | undefined) ?? []) as TitleLineOverride[],
coverTitle,
}
return result
}
/**
* 从 URL 参数或编辑计划 ID 加载表单配置
*/
@@ -27,14 +178,7 @@ export function usePlanConfigLoader({
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
title_config?: Record<string, unknown>
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
@@ -43,16 +187,8 @@ export function usePlanConfigLoader({
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev: TitleSettings) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
const tc = config.title_config as TitleConfig & Record<string, unknown>
setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
@@ -76,15 +212,8 @@ export function usePlanConfigLoader({
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
const tc2 = cfg.title_config as unknown as TitleConfig & Record<string, unknown>
setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc2))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
@@ -208,6 +208,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
tts_style: props.ttsStyle || undefined,
}
: {}),
dedup_enabled: dedupEnabled,
@@ -240,8 +241,91 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
: {}),
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
italic: props.titleSettings.italic,
stroke: props.titleSettings.stroke
? {
enabled: true,
width: props.titleSettings.strokeWidth ?? 4,
color: props.titleSettings.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.shadow
? {
enabled: true,
offset_x: props.titleSettings.shadowOffsetX ?? 2,
offset_y: props.titleSettings.shadowOffsetY ?? 2,
blur: props.titleSettings.shadowBlur ?? 4,
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: props.titleSettings.lineHeight ?? 1.2,
margin_top: props.titleSettings.marginTop ?? 24,
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
...(props.titleSettings.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.bgColor,
padding: props.titleSettings.bgPadding,
radius: props.titleSettings.bgRadius,
},
}
: { background: { enabled: false } }),
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
line_index: lo.line_index,
text: lo.text,
size: lo.size,
color: lo.color,
bold: lo.bold,
italic: lo.italic,
stroke: lo.stroke,
highlights: lo.highlights?.map((h) => ({
word: h.word,
color: h.color,
bold: h.bold,
scale: h.scale,
})),
})),
...(props.titleSettings.coverTitle
? {
cover_title_config: {
title: props.titleSettings.coverTitle.title,
font: props.titleSettings.coverTitle.font,
font_size: props.titleSettings.coverTitle.size,
font_color: props.titleSettings.coverTitle.color,
bold: props.titleSettings.coverTitle.bold,
italic: props.titleSettings.coverTitle.italic,
position: props.titleSettings.coverTitle.position,
stroke: props.titleSettings.coverTitle.stroke
? {
enabled: true,
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.coverTitle.shadow
? {
enabled: true,
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
color:
props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
...(props.titleSettings.coverTitle.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.coverTitle.bgColor,
padding: props.titleSettings.coverTitle.bgPadding,
radius: props.titleSettings.coverTitle.bgRadius,
},
}
: { background: { enabled: false } }),
},
}
: {}),
},
}
: {}),
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react"
import { TITLE_PRESETS } from "../../constants"
import { TITLE_PRESETS as NEW_TITLE_PRESETS } from "@/components/title/constants"
import type { TitleSettings } from "../../types"
interface UseTitleStyleUpdatersOptions {
@@ -97,26 +98,45 @@ export function useTitleStyleUpdaters({
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
}, [titleSettings, onTitleSettingsChange])
/** 应用预设:只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
/** 应用预设(支持新预设细粒度字段) */
const applyPreset = useCallback(
(presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
// 优先匹配新预设(10个爆款预设),fallback 旧预设
const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey)
const oldPreset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (newPreset) {
onTitleSettingsChange({
...titleSettings,
...(newPreset.style as Partial<TitleSettings>),
// 清除逐行覆盖
lineOverrides: [],
})
return
}
if (!oldPreset) return
onTitleSettingsChange({
...titleSettings,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
color: oldPreset.style.color,
bold: oldPreset.style.bold,
italic: oldPreset.style.italic,
stroke: oldPreset.style.stroke,
shadow: oldPreset.style.shadow,
})
},
[titleSettings, onTitleSettingsChange],
)
/** 通用字段更新(patch */
const updateStyle = useCallback(
(patch: Partial<TitleSettings>) => {
onTitleSettingsChange({ ...titleSettings, ...patch })
},
[titleSettings, onTitleSettingsChange],
)
return {
activePreset,
titlePresets: TITLE_PRESETS,
titlePresets: NEW_TITLE_PRESETS,
updateTitle,
toggleAiAutoSelect,
updatePosition,
@@ -129,5 +149,6 @@ export function useTitleStyleUpdaters({
toggleStroke,
toggleShadow,
applyPreset,
updateStyle,
}
}
+83 -25
View File
@@ -3,8 +3,9 @@
*/
import type { AssetItem } from "@/api/assets"
import type { TitleLineOverride } from "@/components/title/types"
/* ── 标题设置 ── */
/* ── 标题设置(#2001 升级:新增描边/阴影/背景/逐行/封面独立样式/排版字段) ── */
export interface TitleSettings {
aiAutoSelect: boolean
title: string
@@ -19,6 +20,58 @@ export interface TitleSettings {
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
posX: number | null
posY: number | null
/* ── 排版(P0 ── */
/** 行距倍数,默认 1.2 */
lineHeight: number
/** 顶部边距(position=toppx @720p */
marginTop: number
/** 每行最大字符数(4-20),0=不自动换行 */
maxCharsPerLine: number
/* ── 描边参数化(P0) ── */
strokeWidth: number
strokeColor: string
/* ── 阴影参数化(P1) ── */
shadowOffsetX: number
shadowOffsetY: number
shadowBlur: number
shadowColor: string
/* ── 背景色块(P1) ── */
bgEnabled: boolean
bgColor: string
bgPadding: number
bgRadius: number
/* ── 逐行独立样式(P1) ── */
lineOverrides: TitleLineOverride[]
/* ── 封面独立标题(P1):null=沿用主标题 ── */
coverTitle: null | {
title?: string
font?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: boolean
strokeWidth?: number
strokeColor?: string
shadow?: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineHeight?: number
maxCharsPerLine?: number
}
}
/* ── 智能匹配结果 ── */
@@ -49,28 +102,33 @@ export interface StepDef {
label: string
}
/* ── 标题预设样式 ── */
export interface TitlePresetStyle {
size: number
color: string
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
}
export interface TitlePreset {
key: string
label: string
style: TitlePresetStyle
previewStyle: Record<string, string | number>
}
/* ── 生成结果视频 ── */
export interface GeneratedVideoResult {
id: string
url: string
thumbnail: string
duration: number
title: string
/** 旧版 TitleSettings 的默认值字段(P0/P1 新字段补齐默认值) */
export const DEFAULT_TITLE_SETTINGS_FULL: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 0,
strokeWidth: 4,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
coverTitle: null,
}
+102 -89
View File
@@ -39,6 +39,7 @@ import { getDiscountPriceCents } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import "./Plans.css"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
const { Title, Text, Paragraph } = Typography
@@ -249,17 +250,23 @@ const Plans: React.FC = () => {
return (
<div className="xx-plans-page">
<PageHead
title="会员与积分"
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
title={ENABLE_CREDIT_SYSTEM ? "会员与积分" : "会员订阅"}
description={
ENABLE_CREDIT_SYSTEM
? "开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
: "开通会员解锁全部功能"
}
actions={
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
</Button>
</Space>
ENABLE_CREDIT_SYSTEM ? (
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
</Button>
</Space>
) : null
}
/>
@@ -296,13 +303,15 @@ const Plans: React.FC = () => {
)}
</div>
</div>
<div>
<Text type="secondary"></Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
{ENABLE_CREDIT_SYSTEM && (
<div>
<Text type="secondary"></Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
</div>
</div>
</div>
)}
{!isMember && freeLimit > 0 && (
<div>
<Text type="secondary"></Text>
@@ -319,18 +328,20 @@ const Plans: React.FC = () => {
)}
</Space>
</Col>
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
</Button>
</Col>
{ENABLE_CREDIT_SYSTEM && (
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
</Button>
</Col>
)}
</Row>
</Card>
@@ -461,69 +472,71 @@ const Plans: React.FC = () => {
</Col>
</Row>
{/* 积分充值 */}
<div id="points-packages">
<Title level={4} style={{ marginTop: 40 }}>
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
</Text>
</Tooltip>
</Title>
{/* 积分充值(积分系统关闭时隐藏,代码保留不删除) */}
{ENABLE_CREDIT_SYSTEM && (
<div id="points-packages">
<Title level={4} style={{ marginTop: 40 }}>
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
</Text>
</Tooltip>
</Title>
<Row gutter={[16, 16]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
return (
<Col xs={24} sm={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
hoverable
>
{isHot && <div className="xx-pkg-badge"></div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()}
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">¥{unit.toFixed(3)}/</div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
<Row gutter={[16, 16]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
return (
<Col xs={24} sm={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
hoverable
>
</Button>
</Card>
</Col>
)
})}
</Row>
</div>
{isHot && <div className="xx-pkg-badge"></div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()}
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">¥{unit.toFixed(3)}/</div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
</Button>
</Card>
</Col>
)
})}
</Row>
</div>
)}
</div>
)
}
@@ -98,6 +98,7 @@ const VoiceMaterialLibrary: React.FC = () => {
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStyle,
ttsStatus,
ttsAudioUrl,
ttsError,
@@ -107,6 +108,7 @@ const VoiceMaterialLibrary: React.FC = () => {
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsStyle,
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
@@ -315,6 +317,7 @@ const VoiceMaterialLibrary: React.FC = () => {
text={ttsText}
voiceId={ttsVoiceId}
speed={ttsSpeed}
style={ttsStyle}
status={ttsStatus}
audioUrl={ttsAudioUrl ?? ""}
error={ttsError ?? ""}
@@ -324,6 +327,7 @@ const VoiceMaterialLibrary: React.FC = () => {
onTextChange={setTtsText}
onVoiceChange={setTtsVoiceId}
onSpeedChange={setTtsSpeed}
onStyleChange={setTtsStyle}
onSynthesize={handleTtsSynthesize}
onSave={handleTtsSave}
/>
@@ -1,6 +1,8 @@
import React from "react"
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import type { TtsStyle } from "@/api/tts/styles"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -20,6 +22,8 @@ interface TtsModalProps {
text: string
voiceId: string
speed: number
style: TtsStyle
onStyleChange: (style: TtsStyle) => void
status: TtsStatus
audioUrl: string
error: string
@@ -39,6 +43,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
text,
voiceId,
speed,
style,
onStyleChange,
status,
audioUrl,
error,
@@ -143,6 +149,9 @@ const TtsModal: React.FC<TtsModalProps> = ({
/>
</div>
{/* 配音风格 */}
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
{/* 合成按钮 */}
<Button
buttonType="primary"
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
@@ -18,6 +19,7 @@ export function useTtsSynthesize() {
const [ttsText, setTtsText] = useState("")
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
@@ -59,6 +61,7 @@ export function useTtsSynthesize() {
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
style: ttsStyle,
})
setTtsJobId(resp.job_id)
@@ -89,7 +92,7 @@ export function useTtsSynthesize() {
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed])
}, [ttsText, ttsVoiceId, ttsSpeed, ttsStyle])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
@@ -131,6 +134,7 @@ export function useTtsSynthesize() {
ttsText,
ttsVoiceId,
ttsSpeed,
ttsStyle,
ttsJobId,
ttsStatus,
ttsAudioUrl,
@@ -141,6 +145,7 @@ export function useTtsSynthesize() {
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsStyle,
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
@@ -133,6 +133,7 @@ const VoiceLibrary: React.FC = () => {
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
@@ -140,6 +141,7 @@ const VoiceLibrary: React.FC = () => {
setTtsText,
setTtsSpeed,
setTtsEmotion,
setTtsStyle,
setTtsLanguage,
setTtsOpen,
handleVoiceChange,
@@ -368,6 +370,7 @@ const VoiceLibrary: React.FC = () => {
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsStyle={ttsStyle}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
@@ -381,6 +384,7 @@ const VoiceLibrary: React.FC = () => {
onTtsVoiceChange={handleVoiceChange}
onTtsSpeedChange={setTtsSpeed}
onTtsEmotionChange={setTtsEmotion}
onTtsStyleChange={setTtsStyle}
onTtsLanguageChange={setTtsLanguage}
onTtsSynthesize={handleTtsSynthesize}
onTtsSave={handleTtsSave}
@@ -9,6 +9,7 @@ import LanguageControl from "./tts-modal/LanguageControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import { PRESET_TTS_LANGUAGE_OPTIONS, CLONE_TTS_LANGUAGE_OPTIONS } from "./tts-modal/constants"
/** AI 配音弹窗 */
@@ -18,6 +19,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
@@ -29,6 +31,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
onVoiceChange,
onSpeedChange,
onEmotionChange,
onStyleChange,
onLanguageChange,
onSynthesize,
onSave,
@@ -72,6 +75,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
/>
</div>
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<TtsStyleSelector value={ttsStyle} onChange={onStyleChange} compact />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
{ttsStatus === "done" && ttsAudioUrl && (
@@ -7,6 +7,7 @@ import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./tts-modal/constants"
import type { TtsStyle } from "@/api/tts/styles"
import CloneModal from "@/components/voice/CloneModal"
import CloneDetailModal from "./CloneDetailModal"
import UploadVoiceModal from "./UploadVoiceModal"
@@ -44,6 +45,7 @@ export interface VoiceModalsProps {
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsStyle: TtsStyle
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
@@ -56,6 +58,7 @@ export interface VoiceModalsProps {
onTtsVoiceChange: (id: string) => void
onTtsSpeedChange: (speed: number) => void
onTtsEmotionChange: (emotion: TtsEmotion) => void
onTtsStyleChange: (style: TtsStyle) => void
onTtsLanguageChange: (language: TtsLanguage) => void
onTtsSynthesize: () => void
onTtsSave: () => void
@@ -86,6 +89,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
@@ -97,6 +101,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onTtsVoiceChange,
onTtsSpeedChange,
onTtsEmotionChange,
onTtsStyleChange,
onTtsLanguageChange,
onTtsSynthesize,
onTtsSave,
@@ -139,6 +144,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsStyle={ttsStyle}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
@@ -150,6 +156,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onVoiceChange={onTtsVoiceChange}
onSpeedChange={onTtsSpeedChange}
onEmotionChange={onTtsEmotionChange}
onStyleChange={onTtsStyleChange}
onLanguageChange={onTtsLanguageChange}
onSynthesize={onTtsSynthesize}
onSave={onTtsSave}
@@ -1,6 +1,7 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import type { TtsClonedVoiceOption } from "./VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./constants"
import type { TtsStyle } from "@/api/tts/styles"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -10,6 +11,7 @@ export interface TtsModalProps {
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsStyle: TtsStyle
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
@@ -22,6 +24,7 @@ export interface TtsModalProps {
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onEmotionChange: (emotion: TtsEmotion) => void
onStyleChange: (style: TtsStyle) => void
onLanguageChange: (language: TtsLanguage) => void
onSynthesize: () => void
onSave: () => void
@@ -11,6 +11,7 @@ import {
type TtsEmotion,
type TtsLanguage,
} from "../components/tts-modal/constants"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -42,6 +43,7 @@ export function useTtsSynthesize({
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsEmotion, setTtsEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [ttsLanguage, setTtsLanguage] = useState<TtsLanguage>(DEFAULT_TTS_LANGUAGE)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
@@ -83,6 +85,7 @@ export function useTtsSynthesize({
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
emotion: ttsEmotion,
style: ttsStyle,
language: effectiveLang,
})
setTtsJobId(resp.job_id)
@@ -114,7 +117,7 @@ export function useTtsSynthesize({
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsLanguage, clonedVoices])
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsStyle, ttsLanguage, clonedVoices])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
@@ -137,6 +140,7 @@ export function useTtsSynthesize({
setTtsVoiceId("")
setTtsSpeed(1.0)
setTtsEmotion(DEFAULT_TTS_EMOTION)
setTtsStyle(DEFAULT_TTS_STYLE)
setTtsLanguage(DEFAULT_TTS_LANGUAGE)
setTtsStatus("idle")
setTtsAudioUrl(null)
@@ -168,6 +172,7 @@ export function useTtsSynthesize({
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsStyle,
ttsLanguage,
ttsJobId,
ttsStatus,
@@ -181,6 +186,7 @@ export function useTtsSynthesize({
setTtsVoiceId,
setTtsSpeed,
setTtsEmotion,
setTtsStyle,
setTtsLanguage,
setTtsOpen,
// 覆写 onVoiceChange(带语言回退)
+20 -4
View File
@@ -8,6 +8,7 @@
* - subscription: GET /subscription/currentplan_id + billing_cycle
*/
import { create } from "zustand"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points"
import { getCurrentSubscription } from "@/api/subscription"
import type {
@@ -49,14 +50,29 @@ export const usePointsStore = create<PointsState>((set, get) => ({
init: async () => {
// 已加载过不重复拉取
if (get().balance && get().rules && get().subscription) return
// 积分系统关闭时:只要 subscription/membership 已有值就跳过;开启时需 balance+rules+subscription 齐了才跳过
if (ENABLE_CREDIT_SYSTEM) {
if (get().balance && get().rules && get().subscription) return
} else {
if (get().subscription && get().membership) return
}
set({ loading: true, error: null })
try {
// 积分系统关闭时不拉取余额/规则/每日额度,但仍拉会员/订阅用于 VIP 标识展示
const balancePromise = ENABLE_CREDIT_SYSTEM
? getPointsBalance().catch(() => null)
: Promise.resolve(null)
const rulesPromise = ENABLE_CREDIT_SYSTEM
? getPointsRules().catch(() => null)
: Promise.resolve(null)
const dailyUsagePromise = ENABLE_CREDIT_SYSTEM
? getDailyUsage().catch(() => null)
: Promise.resolve(null)
const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([
getPointsBalance().catch(() => null),
getPointsRules().catch(() => null),
balancePromise,
rulesPromise,
getCurrentSubscription().catch(() => null),
getDailyUsage().catch(() => null),
dailyUsagePromise,
getMembership().catch(() => null),
])
set({
@@ -493,6 +493,41 @@ class RenderAdapter:
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
return None
def _resolve_clip_has_text(self, clips: list[Any]) -> list[bool] | None:
"""#1970:按源视频片段顺序解析 atom_clip.ai_tags.has_text。
顺序与 UnifiedRenderService 的「非 audio 源片段」口径一致。
仅当 atom_clip 存在 ai_tags 字典且 has_text 显式为 False 时标记为
无文字(允许 hflip);atom_clip_id 缺失、ai_tags 未生成、has_text 为
true/null/非布尔值时一律按有文字处理(保守不翻转)。
查询失败时返回 None,渲染层回退到全保守路径。
"""
video_clips = [c for c in clips if getattr(c, "clip_type", "main") != "audio"]
atom_ids: list[str] = []
seen: set[str] = set()
for c in video_clips:
atom_id = getattr(c, "atom_clip_id", "") or ""
if atom_id and atom_id not in seen:
seen.add(atom_id)
atom_ids.append(atom_id)
if not atom_ids:
return None
try:
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
atom_clips = SQLAlchemyAssetAtomClipRepository(self._db).find_by_ids(atom_ids)
except Exception as exc:
logger.warning("[render-adapter] atom_clip ai_tags 查询失败,hflip 全量保守处理: %s", exc)
return None
has_text_map: dict[str, bool] = {}
for ac in atom_clips:
ai_tags = getattr(ac, "ai_tags", None)
no_text = isinstance(ai_tags, dict) and ai_tags.get("has_text") is False
has_text_map[ac.id] = not no_text
return [has_text_map.get((getattr(c, "atom_clip_id", "") or ""), True) for c in video_clips]
def _do_render(
self,
plan: Any,
@@ -542,6 +577,7 @@ class RenderAdapter:
)
# 4. 执行统一渲染
clip_has_text = self._resolve_clip_has_text(clips)
render_svc = UnifiedRenderService(
plan=plan,
clips=clips,
@@ -552,6 +588,7 @@ class RenderAdapter:
bgm_path=bgm_path,
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
clip_has_text=clip_has_text,
)
result = render_svc.render()
@@ -155,6 +155,7 @@ class UnifiedRenderService:
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
bgm_path: str | None = None, # BGM 本地文件路径
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text
):
self.plan = plan
self.clips = clips
@@ -167,6 +168,8 @@ class UnifiedRenderService:
self.asr_service = asr_service
self.bgm_path = bgm_path
self.voiceover_audio_path = voiceover_audio_path
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
self._clip_has_text = clip_has_text
self._transition_engine = TransitionEngine(default_duration=transition_duration)
self._speed_engine = SpeedEngine()
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
@@ -186,7 +189,9 @@ class UnifiedRenderService:
种子 hash(generation_task_id + video_index)%10000,同一任务重渲结果一致。
dedup_enabled=False 时返回 None,调用方不注入任何微变换。
P1 字幕检测:无可靠的片段文字轨道信息,hflip 一律关闭(宁可不翻转)。
hflip 放开(#1970):clip_has_text 来自 atom_clip.ai_tags.has_text
仅 AI 明确判定无文字的片段可参与 50% 翻转;未打标签 / has_text 为
true/null 或缺位时一律视为有文字,保持保守不翻转。
"""
if self._micro_plan_loaded:
return self._micro_plan_cache
@@ -200,11 +205,14 @@ class UnifiedRenderService:
cfg = self.plan.config or {}
task_id = str(cfg.get("generation_task_id", "") or "")
video_index = int(cfg.get("video_index", 0) or 0)
# self._clip_has_text 顺序与非 audio 源片段一致;
# None(未提供检测,如内存直渲/旧任务)→ 纯函数层按全有文字保守处理;
# 列表短于片段数时缺位片段同样按有文字处理
self._micro_plan_cache = build_micro_transform_plan(
task_id,
video_index,
clip_count,
clip_has_text=None, # P1 保守策略:全部按有文字处理,不翻转
clip_has_text=self._clip_has_text,
enable_bgm_offset=bool(cfg.get("bgm")),
)
except Exception as e:
+7
View File
@@ -28,6 +28,10 @@ celery_app.conf.imports = (
"worker_app.tasks.health",
"worker_app.tasks.ingest",
"worker_app.tasks.atom_clips",
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
# "Received unregistered task of type 'worker.tag_atom_clip'"
"worker_app.tasks.atom_clip_tagging",
"worker_app.tasks.backfill_atom_clip_tags",
"worker_app.tasks.classification",
"worker_app.tasks.generation",
"worker_app.tasks.voice_extraction",
@@ -44,6 +48,9 @@ celery_app.conf.imports = (
# PYTHONPATH=/app/apps/api 下,app.tasks.lipsync_tts 可直接导入且不触发 apps/api/__init__.py
# apps/api/__init__.py 会 from .main import app,级联加载整个 FastAPI 栈,Worker 中不需要且会导致注册失败)
"app.tasks.lipsync_tts",
# #1998 GPU MuseTalk 异步推理:wait_for_result→签名 URL→回写 lipsync_jobs
# 必须在 Worker 侧注册,否则 apply_async 消息无人消费,job 永远卡在 processing
"app.tasks.lipsync_gpu",
)
# Celery Beat 定时任务调度
+8
View File
@@ -57,6 +57,14 @@ def __getattr__(name: str):
from .atom_clips import generate_atom_clips
return generate_atom_clips
elif name == "tag_atom_clip_task":
from .atom_clip_tagging import tag_atom_clip_task
return tag_atom_clip_task
elif name == "backfill_atom_clip_tags":
from .backfill_atom_clip_tags import backfill_atom_clip_tags
return backfill_atom_clip_tags
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,98 @@
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
为单个 atom_clip 调用视觉 AI 生成结构化标签,并更新到 ai_tags 字段。
失败不阻断流程(降级为仅继承素材标签)。
任务名:worker.tag_atom_clip
"""
from __future__ import annotations
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.atom_clip_tagger import tag_atom_clip
from packages.shared.ai_client import get_doubao_client
from packages.shared.mediakit_client import get_mediakit_client
from packages.shared.storage import get_shared_storage_service
logger = get_task_logger(__name__)
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
"""为单个原子片段生成 AI 标签.
Args:
atom_clip_id: 原子片段 ID。
force: True 时允许覆盖只有 inherited_tags 的降级记录
(视觉 API 曾失败写入的占位标签,#1970)。
已有完整标签(含 has_text)始终跳过,保证幂等。
Returns:
任务结果 dictstatus / clip_id / ai_tags(部分字段)。
"""
db = SessionLocal()
try:
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
asset_repo = SQLAlchemyAssetRepository(db)
clip = atom_repo.find_by_id(atom_clip_id)
if clip is None:
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
# 已有完整标签则跳过(幂等);force 仅放行缺失 has_text 的降级记录
if clip.ai_tags is not None:
has_real_tags = isinstance(clip.ai_tags, dict) and "has_text" in clip.ai_tags
if has_real_tags or not force:
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
# 获取素材信息
asset = asset_repo.find_by_id(clip.asset_id)
if asset is None:
return {"status": "skipped", "reason": "asset not found", "clip_id": atom_clip_id}
# 获取视频可访问 URL
storage = get_shared_storage_service()
video_url = storage.get_download_url(asset.storage_key, expires_seconds=3600)
# 初始化客户端
doubao_client = get_doubao_client()
mediakit_client = get_mediakit_client()
# 调用 tagger
ai_tags = tag_atom_clip(
clip=clip,
video_url=video_url,
doubao_client=doubao_client,
mediakit_client=mediakit_client,
storage=storage,
)
# 更新数据库
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
logger.info(
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
atom_clip_id,
{k: v for k, v in ai_tags.items() if k != "inherited_tags"},
)
return {
"status": "completed",
"clip_id": atom_clip_id,
"has_ai_tags": any(v for k, v in ai_tags.items() if k != "inherited_tags" and v),
}
except Exception as exc:
db.rollback()
logger.exception("[atom_clip_tagging] clip_id=%s 失败: %s", atom_clip_id, exc)
# 可重试异常
if self.request.retries < self.max_retries:
raise self.retry(exc=exc) from None
return {"status": "failed", "clip_id": atom_clip_id, "error": str(exc)}
finally:
db.close()
@@ -3,6 +3,8 @@
素材入库预处理完成(ingest 置 READY)后异步触发:
根据素材时长和已缓存的 scdet 切换点计算原子片段并落库。
失败不阻断素材入库主流程(atom_clips 未就绪时选片有内存兜底)。
P2 增强:切片完成后自动链式触发 AI 标签任务(每个 clip 一个 tag_atom_clip 任务)。
"""
from __future__ import annotations
@@ -72,6 +74,10 @@ def generate_atom_clips(asset_id: str) -> dict:
asset_id,
len(clips),
)
# P2 增强:链式触发 AI 标签任务(每个 clip 一个异步任务)
_dispatch_tagging_tasks(clips)
return {"status": "completed", "asset_id": asset_id, "clips_count": len(clips)}
except Exception as exc: # noqa: BLE001 - 后台任务兜底,失败不阻断主流程
db.rollback()
@@ -79,3 +85,25 @@ def generate_atom_clips(asset_id: str) -> dict:
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
finally:
db.close()
def _dispatch_tagging_tasks(clips: list) -> None:
"""为每个新建片段发送 AI 标签异步任务.
失败不阻断(标签任务是锦上添花,不影响核心流程)。
"""
try:
for clip in clips:
celery_app.send_task(
"worker.tag_atom_clip",
args=[clip.id],
)
logger.info(
"[atom_clips] 已发送 %d 个 AI 标签任务",
len(clips),
)
except Exception as e:
logger.warning(
"[atom_clips] 发送 AI 标签任务失败(不影响切片结果): %s",
e,
)
@@ -0,0 +1,106 @@
"""批量回填 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
查找所有 ai_tags IS NULL 的 atom_clips,分批触发 tag_atom_clip 任务。
可通过 API 路由触发(管理员权限)。
任务名:worker.backfill_atom_clip_tags
"""
from __future__ import annotations
import time
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
logger = get_task_logger(__name__)
# 默认批量参数
DEFAULT_BATCH_SIZE = 10
DEFAULT_BATCH_INTERVAL = 5 # 秒
@celery_app.task(name="worker.backfill_atom_clip_tags")
def backfill_atom_clip_tags(
batch_size: int = DEFAULT_BATCH_SIZE,
batch_interval: int = DEFAULT_BATCH_INTERVAL,
max_clips: int = 0,
force: bool = False,
) -> dict:
"""批量回填未打标的 atom_clips.
Args:
batch_size: 每批处理数量,默认 10。
batch_interval: 每批间隔秒数,默认 5。
max_clips: 最大处理总数,0 表示不限。
force: True 时连同只有 inherited_tags 的降级记录一起强制重打
(视觉 API 曾失败、DOUBAO_VISION_MODEL 修复后重跑用,#1970)。
Returns:
任务结果 dicttotal_submitted / batches。
"""
db = SessionLocal()
try:
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
total_submitted = 0
batches = 0
while True:
# 查找未打标的片段
remaining = max_clips - total_submitted if max_clips > 0 else batch_size
fetch_limit = min(batch_size, remaining) if max_clips > 0 else batch_size
untagged = atom_repo.find_untagged(limit=fetch_limit, include_downgraded=force)
if not untagged:
break
# 逐个发送 tag 任务
for clip in untagged:
try:
celery_app.send_task(
"worker.tag_atom_clip",
args=[clip.id],
kwargs={"force": force},
)
total_submitted += 1
except Exception as e:
logger.warning(
"[backfill] 提交任务失败 clip_id=%s: %s",
clip.id,
e,
)
batches += 1
logger.info(
"[backfill] 第 %d 批完成,已提交 %d 个任务",
batches,
total_submitted,
)
# 检查是否达到上限
if max_clips > 0 and total_submitted >= max_clips:
break
# 批间间隔
time.sleep(batch_interval)
logger.info(
"[backfill] 回填完成: total_submitted=%d batches=%d",
total_submitted,
batches,
)
return {
"status": "completed",
"total_submitted": total_submitted,
"batches": batches,
}
except Exception as exc:
logger.exception("[backfill] 回填失败: %s", exc)
return {"status": "failed", "error": str(exc)}
finally:
db.close()
+11
View File
@@ -234,6 +234,9 @@ DOUBAO_TIMEOUT=60
# 最大重试次数
DOUBAO_MAX_RETRIES=2
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
@@ -252,3 +255,11 @@ DOUYIN_DEBUG_ERRORS=false
TIKHUB_API_KEY=${TIKHUB_API_KEY}
# P2: apizero.cn(国内付费,https://apizero.cn
APIZERO_API_KEY=${APIZERO_API_KEY}
# ==================== GPU MuseTalk Worker(反向轮询) ====================
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
GPU_TASK_TIMEOUT_SECONDS=900
USE_GPU_LIPSYNC=false
GPU_LIPSYNC_POLL_INTERVAL=5
GPU_LIPSYNC_WAIT_TIMEOUT=1200
GPU_WORKER_STALE_SECONDS=300
+11
View File
@@ -251,6 +251,9 @@ DOUBAO_TIMEOUT=60
# 最大重试次数
DOUBAO_MAX_RETRIES=2
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
@@ -269,3 +272,11 @@ DOUYIN_DEBUG_ERRORS=false
TIKHUB_API_KEY=${TIKHUB_API_KEY}
# P2: apizero.cn(国内付费,https://apizero.cn
APIZERO_API_KEY=${APIZERO_API_KEY}
# ==================== GPU MuseTalk Worker(反向轮询) ====================
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
GPU_TASK_TIMEOUT_SECONDS=900
USE_GPU_LIPSYNC=true
GPU_LIPSYNC_POLL_INTERVAL=5
GPU_LIPSYNC_WAIT_TIMEOUT=1200
GPU_WORKER_STALE_SECONDS=300
+30
View File
@@ -0,0 +1,30 @@
# ============================================================
# MuseTalk GPU Worker 环境变量
# 部署到 RTX2060 电脑后,复制为 .env 并修改值
# ============================================================
# SaaS API 基础 URLstaging / production
API_BASE_URL=https://staging-api.xiaoxiajianji.com
# API_BASE_URL=https://api.xiaoxiajianji.com # 生产
# 长期 API Token,必须与服务端 GPU_WORKER_TOKEN 一致(找后端拿)
GPU_WORKER_TOKEN=replace-with-real-token
# 本机 Worker 唯一 ID(默认自动生成 hostname+MAC 后4位,可手动指定)
# WORKER_ID=rtx2060-0193
# 本地 MuseTalk 地址(默认 http://127.0.0.1:7861
MUSE_TALK_URL=http://127.0.0.1:7861
# 轮询/心跳/超时(秒)
POLL_INTERVAL=5
HEARTBEAT_INTERVAL=15
# 下载/推理/上传 HTTP 超时,需与服务端 GPU_TASK_TIMEOUT_SECONDS 对齐(默认 900
REQUEST_TIMEOUT=900
# 单个任务本地最大重试次数(仅网络/MuseTalk 瞬时错误才重试,默认 1)
TASK_MAX_RETRY=1
# 推理期间任务心跳间隔(秒,独立线程,无需改动)
TASK_HEARTBEAT_INTERVAL=30
# 输入视频最短时长(秒),小于则直接上报失败,不调用 MuseTalk
MIN_VIDEO_DURATION_SECONDS=3
+353
View File
@@ -0,0 +1,353 @@
# MuseTalk GPU Worker 部署指南
本目录包含两个组件:
1. **gpu_worker.py**:反向轮询客户端,部署在 RTX2060 本地,轮询 SaaS API 拉取口型任务,调用本地 MuseTalk 服务推理,上传结果回 SaaS。
2. **musetalk_server.py**MuseTalk Flask HTTP 服务端,接收 gpu_worker.py 的推理请求,调用 MuseTalk 模型生成口型同步视频。
---
## 一、环境准备
### 1.1 硬件要求
- GPU: NVIDIA RTX 2060 或更高(显存 ≥ 6GB
- CUDA: 11.8+
- Python: 3.10+
- ffmpeg: 需安装并加入 PATH
### 1.2 安装依赖
```bash
cd deploy/gpu_worker
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
---
## 二、MuseTalk 服务端部署(musetalk_server.py
### 2.1 配置环境变量
复制 `.env.example``.env`,修改配置:
```bash
cp .env.example .env
vim .env
```
关键配置:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `MUSE_PORT` | 监听端口 | `7861` |
| `MUSE_INFERENCE_TIMEOUT` | 推理超时秒数 | `600` |
| `MUSE_VIDEO_MAX_MB` | 视频上传大小限制 MB | `100` |
| `MUSE_AUDIO_MAX_MB` | 音频上传大小限制 MB | `20` |
| `MUSE_DEFAULT_FPS` | 视频 fps 兜底值 | `25.0` |
| `MUSE_TEMP_DIR` | 临时文件目录 | `/tmp/musetalk_$$` |
| `MUSE_VIDEO_ENCODER` | 兜底循环视频时的编码器:`auto`(优先 h264_nvenc,失败回退 libx264/`h264_nvenc`/`libx264` | `auto` |
### 2.2 更新部署(v2 性能修复,必做)
> ⚠️ 2026-09-20 v2 架构:修复 16 倍性能回归。旧版在推理前 loop 视频导致 MuseTalk 处理帧数翻倍、RTX2060 推理 >200s、nginx 504。**必须重新拉取并重启**:
```bash
# 在 RTX2060 上备份旧文件并拉取新版本
cp ~/projects/MuseTalk/musetalk_server.py ~/projects/MuseTalk/musetalk_server.py.bak
wget -O ~/projects/MuseTalk/musetalk_server.py \
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/musetalk_server.py"
# 重启服务
sudo systemctl restart musetalk-server
sudo systemctl status musetalk-server
curl http://127.0.0.1:7861/health
```
v2 架构核心变化:
- **MuseTalk 直传全量音频**:不再在推理前用 ffmpeg 循环视频。MuseTalk 原生支持长音频输入,内部自动循环视频帧。推理时间不变(~14s/5s 视频)
- **ffmpeg 只做快速封装**`-c:v copy -c:a aac -shortest`,秒级完成,不重编码
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关,MuseTalk 原生处理
### 2.3 启动服务
```bash
# 前台运行(调试用)
python musetalk_server.py
# 后台运行(生产用 systemd
sudo systemctl start musetalk-server
sudo systemctl enable musetalk-server
```
### 2.4 验证健康检查
```bash
curl http://127.0.0.1:7861/health
```
应返回:
```json
{
"status": "healthy",
"gpu": {
"gpu_name": "NVIDIA GeForce RTX 2060",
"memory_total_mb": 6144,
"memory_used_mb": 1024,
"memory_free_mb": 5120
},
"current_task": {
"task_id": null,
"running": false,
"elapsed_seconds": 0.0
},
"timestamp": 1700000000.0
}
```
---
## 三、GPU Worker 客户端部署(gpu_worker.py
### 3.1 配置环境变量
复制 `.env.example``.env`,修改配置:
```bash
cp .env.example .env
vim .env
```
关键配置:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `API_BASE_URL` | SaaS API 基础 URL | `https://staging-api.xiaoxiajianji.com` |
| `GPU_WORKER_TOKEN` | 长期 API Token(与服务端一致) | - |
| `MUSE_TALK_URL` | 本地 MuseTalk 服务地址 | `http://127.0.0.1:7861` |
| `POLL_INTERVAL` | 轮询间隔秒 | `5` |
| `HEARTBEAT_INTERVAL` | 空闲心跳间隔秒 | `15` |
| `REQUEST_TIMEOUT` | HTTP 请求超时秒 | `900` |
| `TASK_MAX_RETRY` | 本地最大重试次数 | `1` |
| `TASK_HEARTBEAT_INTERVAL` | 推理期间任务心跳间隔秒 | `30` |
| `MIN_VIDEO_DURATION_SECONDS` | 最短输入视频时长秒 | `3` |
### 3.2 启动 Worker
```bash
# 前台运行(调试用)
python gpu_worker.py
# 后台运行(生产用 systemd
sudo systemctl start xiaoxia-gpu-worker
sudo systemctl enable xiaoxia-gpu-worker
```
### 3.3 验证启动日志
应看到:
```
============================================================
MuseTalk GPU Worker 启动
worker_id = rtx2060-xxxx
api_base = https://staging-api.xiaoxiajianji.com
muse_talk = http://127.0.0.1:7861
poll = 5.0s / heartbeat = 15.0s
============================================================
MuseTalk 健康检查通过: {...}
注册/心跳成功
```
---
## 四、常见问题排查
| 现象 | 可能原因 / 排查 |
|---|---|
| 日志 401 `Invalid GPU worker token` | `.env``GPU_WORKER_TOKEN` 与服务端不一致 |
| 日志 `MuseTalk 健康检查未通过` | 本地 MuseTalk 没启动,或端口不是 7861;`curl http://127.0.0.1:7861/health` 验证 |
| 任务长时间不被拉取 | Worker 和服务端连不上;检查 API_BASE_URL 是否可达、Token 是否正确 |
| 推理后上传 OSS 失败 | 本地出口网络被防火墙拦截 OSS 域名(oss-cn-hangzhou.aliyuncs.com |
| 服务端看到任务回退到 pending 重试 | 任务心跳真正超时(默认 900s):Worker 进程崩溃/断网,或推理彻底卡死;正常长推理期间心跳线程每 30s 续期,不会回退 |
| 日志 `MuseTalk 推理超时或连接失败` | 视频太长或显存不足;可临时调大 REQUEST_TIMEOUT(服务端 GPU_TASK_TIMEOUT_SECONDS 需同步调大),或限制输入视频时长 |
| 日志 `视频过短(x.xxs < 3s` | 输入视频不足 3sMuseTalk 对短视频会 division by zero,已在本地直接上报失败;可用 MIN_VIDEO_DURATION_SECONDS 调整阈值 |
| MuseTalk 服务端 503 `GPU 正在处理其他任务` | 并发请求被锁拒绝,等当前推理完成即可 |
| MuseTalk 服务端 504 `推理超时` | 推理超过 MUSE_INFERENCE_TIMEOUT,客户端会调 /cancel 终止服务端任务 |
---
## 五、安全注意事项
- `.env` 包含长期 Token,文件权限设为 600(`chmod 600 .env`
- Token 泄露要立即在服务端更换 `GPU_WORKER_TOKEN` 并重启 Worker
- Worker 只需要出站访问 SaaS API 和 OSS,不需要开放任何入站端口
- MuseTalk 服务端只监听本地 127.0.0.1(或 0.0.0.0 但通过防火墙限制),不暴露到公网
- 临时文件自动清理(推理完成/失败后),无需手动维护
---
## 六、工程改进记录(musetalk_server.py
相比原 `worker.py`,修复了以下 8 个 bug
1. **Flask 单线程阻塞**`app.run(threaded=True)`,推理时 `/health` 仍可响应
2. **fps=0 除零崩溃**`_get_video_fps()` 兜底 `MUSE_DEFAULT_FPS`
3. **ffmpeg 不检查返回码**`subprocess.run(check=True)` + 超时检查,失败立即报错
4. **无并发锁**`threading.Lock` 控制并发,第二请求立即 503
5. **无推理超时**:线程 join timeout,超时返回 504 并调 `/cancel`
6. **结果文件不清理**:推理完成/失败后自动删除临时目录
7. **无人脸检测兜底**MuseTalk 推理内部处理(TODO: 可在 `_run_inference` 前置检查)
8. **上传无大小限制**`_check_file_size()` 校验,超限返回 413
新增:
- `/cancel` 端点:终止当前推理任务,清理临时文件
- `/health` 端点:返回 GPU 显存信息和当前任务状态
2026-09-20 追加修复(音轨正确性,上线阻断级):
9. **音轨未替换(严重)**:旧最终封装让 ffmpeg 默认选流,结果保留了源视频自带音轨(与画面相关系数 0.9998,与 TTS 无关)。改为 `_mux_video_with_audio()` 统一封装,强制 `-map 0:v:0 -map 1:a:0`,画面取 MuseTalk 无声产物、音轨只取驱动音频
10. **音视频时长不对齐**TTS 长于原视频时 `-shortest` 会截短语音。改为探测双方时长,音频更长时 `-stream_loop -1` 循环画面 + `h264_nvenc` 硬件重编码(`MUSE_VIDEO_ENCODER=auto`,失败回退 libx264+ `-t <音频时长>`;不循环时 `-c:v copy` 秒封装
- 开关 `MUSE_ENABLE_VIDEO_LOOP=0` 可关闭循环;请求也支持 form 参数 `enable_video_loop` 单任务覆盖
2026-09-20 v2 架构重构(性能回归修复,上线阻断级):
11. **16 倍性能回归**#9/#10 的实现虽然音轨正确,但在某些集成场景下(推理前 loop 视频再喂 MuseTalk)导致推理帧数 ×2.2 + 叠加 ffmpeg 软编码预处理,5s 视频 +11s 音频推理 >200snginx 60s 超时 504
- **正确架构**:MuseTalk 原生支持长音频输入,内部自动循环视频帧。把【原视频】+【全量音频】直传 MuseTalk,输出时长=音频时长
- **ffmpeg 后置快速封装**`-c:v copy -c:a aac -shortest` 秒级完成,不重编码
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底补齐
- **业务侧异步化**POST /lipsync/jobs 创建 GPU 任务后立即返回 `job.status="processing"`,Celery 异步等待结果回写。前端 GET /jobs/{id} 轮询。避免同步阻塞 HTTP 请求 >200s
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关
---
## 七、自动部署
从 2026-09-20 起,GPU 节点配置文件和脚本全部入库到 `deploy/gpu_worker/`,支持一键初始化新节点 + develop 分支 push 后 30 秒内自动拉取更新。
### 7.1 服务架构
每个 GPU 渲染节点运行三个 systemd 单元:
| 单元 | 类型 | 作用 |
|---|---|---|
| `musetalk-worker.service` | simple(常驻) | MuseTalk Flask 推理 API(监听 127.0.0.1:7861 |
| `xiaoxia-gpu-worker.service` | simple(常驻) | 反向轮询 SaaS API 拉口型任务的 Worker 客户端 |
| `gpu-poll.timer` + `gpu-poll.service` | timer(每 30s 触发 oneshot | 轮询 Gitea `deploy/gpu_worker/` 最新 commit,有变更自动执行 update 脚本 |
脚本目录(节点本地):
| 路径 | 来源 | 作用 |
|---|---|---|
| `~/projects/update-gpu-worker.sh` | `scripts/update-gpu-worker.sh` | 备份 → 拉代码 → 重启两个服务 → 健康检查 → 失败回滚 |
| `~/projects/gpu-webhook/poll_and_update.sh` | `scripts/poll_and_update.sh` | 轮询 Gitea API 比对 SHA,有新 commit 时触发 update |
### 7.2 新节点部署步骤
**前置准备**(手动,首次部署必做):
1. 安装 NVIDIA 驱动 + CUDA 11.8+`nvidia-smi` 能看到 GPU
2. 克隆 MuseTalk 代码到 `~/projects/MuseTalk/`,下载模型权重到 `~/projects/MuseTalk/models/musetalk/`(权重约几 GB,不适合自动下载)
3. 创建 Python 虚拟环境 `~/projects/MuseTalk/venv/` 并安装 MuseTalk 依赖(PyTorch CUDA 版等)
4. 创建 Worker 虚拟环境 `/opt/xiaoxia-gpu-worker/venv/``pip install -r requirements.txt`
5. 准备 `.env` 文件(Worker 端):`/opt/xiaoxia-gpu-worker/.env`,填好 `API_BASE_URL``GPU_WORKER_TOKEN``MUSE_TALK_URL` 等(参考 `.env.example`
> ⚠️ 模型权重和 Python 虚拟环境(含 CUDA 版 PyTorch)体积大、安装慢,首次部署必须手动准备;后续脚本只更新 `.py` 文件和配置,不碰权重和 venv。
**一键初始化**
```bash
# 从仓库拉取 setup 脚本并执行(在全新 GPU 机器上以 ying 用户执行)
wget -q -O /tmp/setup-gpu-node.sh \
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/scripts/setup-gpu-node.sh"
bash /tmp/setup-gpu-node.sh
```
脚本自动完成:
1. apt 安装系统依赖(python3、ffmpeg、wget、curl、git
2. 创建必要目录(`~/projects/MuseTalk``~/projects/gpu-webhook``/opt/xiaoxia-gpu-worker`
3. 从仓库拉取三个 systemd 单元文件 + update/poll 脚本到本地
4. 安装 systemd 服务到 `/etc/systemd/system/`
5. 配置 sudo 免密(仅允许 `ying` 用户免密 restart 两个服务、status、journalctl、cp、chmod、tee
6. 首次执行 update 脚本拉取最新 `musetalk_server.py``gpu_worker.py`
7. `systemctl daemon-reload` + enable + start 三个单元
**初始化后检查**
```bash
sudo systemctl status musetalk-worker # 应 active (running)
sudo systemctl status xiaoxia-gpu-worker # 应 active (running)
sudo systemctl status gpu-poll.timer # 应 active (waiting)
curl http://127.0.0.1:7861/health # 应返回 healthy + GPU 显存信息
```
### 7.3 自动更新机制
push 到 `develop` 分支且修改了 `deploy/gpu_worker/` 下任何文件后:
1. `gpu-poll.timer` 每 30 秒触发 `gpu-poll.service`
2. `poll_and_update.sh` 调用 Gitea API 取 `deploy/gpu_worker/` 路径最新 commit SHA
3. 与本地 `~/projects/gpu-webhook/.last_commit` 比对,无变更直接退出
4. 有变更:写入新 SHA → 执行 `update-gpu-worker.sh`
5. `update-gpu-worker.sh` 执行流程:
- 备份当前 `musetalk_server.py` / `gpu_worker.py`(带时间戳后缀)
- wget 拉取最新 `musetalk_server.py``gpu_worker.py`
- 比对 `requirements.txt`,有变化则 pip install
- `sudo systemctl restart musetalk-worker`,等 5 秒
- `sudo systemctl restart xiaoxia-gpu-worker`,等 8 秒
- `curl http://127.0.0.1:7861/health` 健康检查
- 健康 → 写日志退出 0
- 不健康 → 回滚到最新备份 → 重启 → 退出 1(日志记录 rolled back
端到端延迟:从 push 到节点拉到新代码并重启,约 30~60 秒。
### 7.4 手动更新命令
```bash
# 立即手动触发一次更新(不依赖 timer)
bash ~/projects/update-gpu-worker.sh
# 查看更新日志
tail -f /tmp/gpu-worker-update.log
# 查看轮询日志
tail -f /tmp/gpu-poll.log
# 查看服务运行日志
journalctl -u musetalk-worker -f # MuseTalk 推理服务日志
journalctl -u xiaoxia-gpu-worker -f # GPU Worker 客户端日志
journalctl -u gpu-poll.service -f # 轮询/更新触发日志
```
### 7.5 仓库文件清单(自动部署相关)
```
deploy/gpu_worker/
├── musetalk-worker.service # MuseTalk 推理 API 的 systemd 服务
├── gpu-poll.service # 自动更新轮询 oneshot service
├── gpu-poll.timer # 每 30 秒触发轮询的 timer
├── xiaoxia-gpu-worker.service # GPU Worker 客户端 systemd 服务(已有)
├── gpu_worker.py # GPU Worker 客户端脚本(已有,自动更新)
├── musetalk_server.py # MuseTalk Flask 服务端(已有,自动更新)
├── requirements.txt # Worker Python 依赖(已有)
├── .env.example # Worker 环境变量模板(已有)
├── README.md # 本文档
└── scripts/
├── update-gpu-worker.sh # 更新脚本:备份→拉取→重启→健康检查→回滚
├── poll_and_update.sh # 轮询脚本:SHA 比对→触发更新
└── setup-gpu-node.sh # 新节点一键初始化脚本
```
### 7.6 注意事项
- **首次部署必须手动准备**:MuseTalk 代码仓库、模型权重(`models/musetalk/`,几 GB)、MuseTalk 的 Python 虚拟环境(`venv/`,含 CUDA 版 PyTorch)。这些体积大、安装耗时长,不在自动更新范围内。
- **脚本路径写死**:当前脚本路径固定为 `/home/ying/projects/``/opt/xiaoxia-gpu-worker/`,用户名固定 `ying`。后续如有多节点/多用户需求再做参数化。
- **sudo 免密范围最小化**setup 脚本写入 `/etc/sudoers.d/ying-gpu-update`,仅放行 restart/status 两个 GPU 相关服务、daemon-reload、journalctl、cp、chmod、tee,不开放全量 root。
- **回滚只回滚 .py 文件**:健康检查失败只回滚 `musetalk_server.py``gpu_worker.py`,不回滚 pip 依赖(requirements.txt 变化概率低,且 pip 操作本身可能失败)。如需完全回滚,手动 `pip install -r requirements.txt` 指定旧版本。
- **poll 脚本容错**Gitea API 请求失败直接跳过,不触发更新,不会因为网络抖动误重启服务。
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=GPU Worker Auto-Update Poller
[Service]
Type=oneshot
User=ying
ExecStart=/bin/bash /home/ying/projects/gpu-webhook/poll_and_update.sh
StandardOutput=journal
StandardError=journal
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Poll Gitea for GPU worker updates every 30 seconds
[Timer]
OnBootSec=30
OnUnitActiveSec=30
AccuracySec=5
[Install]
WantedBy=timers.target
+490
View File
@@ -0,0 +1,490 @@
"""MuseTalk GPU Worker — 反向轮询模式.
部署在有 RTX2060 的本地电脑上(192.168.0.193),
主动轮询 SaaS API 拉取口型任务、调用本地 MuseTalk 推理、上传结果回 SaaS。
环境变量:
API_BASE_URL SaaS API 基础 URL(不含 /api/v1),如 https://staging-api.xiaoxiajianji.com
GPU_WORKER_TOKEN 长期 API Token(服务端 GPU_WORKER_TOKEN 需一致)
WORKER_ID 本机唯一 ID(默认 hostname+网卡MAC 后4位)
MUSE_TALK_URL 本地 MuseTalk 地址,默认 http://127.0.0.1:7861
POLL_INTERVAL 轮询间隔秒,默认 5
HEARTBEAT_INTERVAL 空闲心跳间隔秒,默认 15
REQUEST_TIMEOUT HTTP 请求超时秒(下载/推理/上传统一使用),默认 900
需与服务端 GPU_TASK_TIMEOUT_SECONDS(默认 900)对齐
TASK_MAX_RETRY 单任务本地最大重试次数(仅对瞬时错误重试),默认 1
TASK_HEARTBEAT_INTERVAL 推理期间任务心跳间隔秒,默认 30
MIN_VIDEO_DURATION_SECONDS 最短输入视频时长秒,小于则直接上报失败,默认 3
用法:
python gpu_worker.py
"""
from __future__ import annotations
import logging
import os
import platform
import socket
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
from typing import Optional
import requests
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("musetalk-worker")
# ── 配置 ────────────────────────────────────────────────────────────
def _env(name: str, default: str = "") -> str:
v = os.environ.get(name, default)
return v.strip() if isinstance(v, str) else default
class Config:
api_base_url: str = _env("API_BASE_URL", "https://staging-api.xiaoxiajianji.com").rstrip("/")
gpu_worker_token: str = _env("GPU_WORKER_TOKEN")
muse_talk_url: str = _env("MUSE_TALK_URL", "http://127.0.0.1:7861").rstrip("/")
poll_interval: float = float(_env("POLL_INTERVAL", "5"))
heartbeat_interval: float = float(_env("HEARTBEAT_INTERVAL", "15"))
# #1970RTX2060 6G 处理 720p 长视频可能 >5min;与服务端
# GPU_TASK_TIMEOUT_SECONDS 默认值对齐为 900,避免推理被本地/服务端先掐断。
request_timeout: float = float(_env("REQUEST_TIMEOUT", "900"))
# 本地只在网络/MuseTalk 瞬时错误时重试 1 次;服务端 MAX_ATTEMPTS=3
# 负责跨 worker/真正超时后的重派发,总尝试次数不再相乘放大。
task_max_retry: int = int(_env("TASK_MAX_RETRY", "1"))
# 推理期间任务心跳间隔(独立线程 POST /gpu/register 带 task_id
task_heartbeat_interval: float = float(_env("TASK_HEARTBEAT_INTERVAL", "30"))
# 输入视频最短时长(秒):过短(如 1s)MuseTalk 会 division by zero
# 本地前置拦截,直接上报 failed,不浪费 GPU 时间
min_video_duration_seconds: float = float(_env("MIN_VIDEO_DURATION_SECONDS", "3"))
worker_id: str = _env("WORKER_ID", "")
@classmethod
def derived_worker_id(cls) -> str:
if cls.worker_id:
return cls.worker_id
# hostname + MAC 后4位 → 稳定唯一 ID
try:
mac = uuid.getnode()
mac_suffix = f"{mac:012x}"[-4:]
except Exception:
mac_suffix = "0000"
host = platform.node() or socket.gethostname() or "rtx2060"
return f"{host}-{mac_suffix}"
# ── 辅助 ─────────────────────────────────────────────────────────────
def _api_headers() -> dict[str, str]:
token = Config.gpu_worker_token
if not token:
logger.warning("GPU_WORKER_TOKEN 未配置,开发模式下会被服务端拒绝(生产环境必须配置)")
return {"Authorization": f"Bearer {token}"} if token else {}
def _check_musetalk_health() -> tuple[bool, dict]:
"""检查本地 MuseTalk 健康状态,返回 (ok, info)."""
try:
r = requests.get(f"{Config.muse_talk_url}/health", timeout=5)
if r.status_code == 200:
try:
return True, r.json()
except Exception:
return True, {}
return False, {"status_code": r.status_code, "body": r.text[:200]}
except Exception as exc:
return False, {"error": str(exc)}
def _register(task_id: Optional[str] = None) -> bool:
"""向服务端注册 / 心跳,附带 GPU 信息。
推理期间的心跳线程传 task_id:服务端会同步刷新该 processing 任务的
last_heartbeat_at,防止长推理被误判超时回收。
"""
ok, info = _check_musetalk_health()
if isinstance(info, dict):
gpu_info = info.get("gpu", info)
free_vram = int(gpu_info.get("free_vram_mb", gpu_info.get("memory_free_mb", 0)) or 0)
gpu_name = gpu_info.get("gpu_name", info.get("gpu_name", ""))
else:
free_vram = 0
gpu_name = ""
if not gpu_name:
# 尝试在 Windows 上读 nvidia-smi
gpu_name = _probe_gpu_name()
payload = {
"worker_id": Config.derived_worker_id(),
"hostname": platform.node(),
"gpu_name": gpu_name,
"free_vram_mb": free_vram,
"capabilities": "musetalk",
}
if task_id:
payload["task_id"] = task_id
try:
r = requests.post(
f"{Config.api_base_url}/api/v1/gpu/register",
json=payload,
headers=_api_headers(),
timeout=15,
)
if r.status_code == 200:
return True
logger.error("注册/心跳失败: HTTP %d body=%s", r.status_code, r.text[:300])
return False
except Exception as exc:
logger.error("注册/心跳异常: %s", exc)
return False
def _probe_gpu_name() -> str:
"""尽力探测 GPU 型号(不强制依赖 pynvml."""
try:
import subprocess
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
stderr=subprocess.DEVNULL,
timeout=5,
)
return out.decode("utf-8", errors="ignore").strip().splitlines()[0].strip()
except Exception:
return ""
def _poll_task() -> Optional[dict]:
"""轮询拉取一条待处理任务;无任务返回 None."""
try:
r = requests.get(
f"{Config.api_base_url}/api/v1/gpu/lipsync/poll",
params={"worker_id": Config.derived_worker_id()},
headers=_api_headers(),
timeout=30,
)
if r.status_code == 204:
return None
if r.status_code == 200:
data = r.json()
return data.get("task")
logger.error("poll 返回 %d: %s", r.status_code, r.text[:300])
return None
except Exception as exc:
logger.error("poll 异常: %s", exc)
return None
def _download(url: str, path: Path) -> bool:
"""下载文件到本地,支持预签名 URL."""
try:
with requests.get(url, stream=True, timeout=Config.request_timeout) as r:
if r.status_code >= 400:
logger.error("下载失败 HTTP %d: %s", r.status_code, url[:120])
return False
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 256):
if chunk:
f.write(chunk)
return path.stat().st_size > 0
except Exception as exc:
logger.error("下载异常 %s: %s", url[:120], exc)
return False
def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[bool, float, str, bool]:
"""调用本地 MuseTalk /inference.
返回 (success, duration_seconds, error_msg, retryable)。
duration 用 ffprobe 读结果视频,失败填 0。
retryable 仅对瞬时错误(连接失败/超时/5xx)为 True;HTTP 4xx、结果过小
等确定性失败不重试,直接上报服务端(服务端 MAX_ATTEMPTS 再决定是否重派发)。
"""
try:
with open(video_path, "rb") as vf, open(audio_path, "rb") as af:
files = {
"video": (video_path.name, vf, "video/mp4"),
"audio": (audio_path.name, af, "application/octet-stream"),
}
r = requests.post(
f"{Config.muse_talk_url}/inference",
files=files,
timeout=Config.request_timeout,
)
if r.status_code != 200:
retryable = r.status_code >= 500
return False, 0.0, f"MuseTalk HTTP {r.status_code}: {r.text[:500]}", retryable
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(r.content)
if out_path.stat().st_size < 1024:
# 确定性失败(推理产物异常),本地重试大概率还是坏的,不重试
return False, 0.0, f"MuseTalk 返回结果过小 ({out_path.stat().st_size} bytes)", False
duration = _probe_duration(out_path)
return True, duration, "", False
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
# 瞬时网络/超时错误,允许本地重试 1 次;同时调 /cancel 让服务端终止僵尸推理
_cancel_musetalk()
return False, 0.0, f"MuseTalk 推理超时或连接失败(>{Config.request_timeout}s", True
except Exception as exc:
return False, 0.0, f"MuseTalk 调用异常: {exc}", False
def _cancel_musetalk() -> None:
"""调 MuseTalk /cancel 端点终止服务端僵尸推理进程,避免超时后任务还在跑占显存."""
try:
r = requests.post(f"{Config.muse_talk_url}/cancel", timeout=10)
if r.status_code == 200:
logger.info("已调 MuseTalk /cancel,服务端终止推理")
else:
logger.warning("MuseTalk /cancel 返回 %d: %s", r.status_code, r.text[:200])
except Exception as exc:
# /cancel 失败不应影响主流程上报
logger.warning("调 MuseTalk /cancel 异常(忽略): %s", exc)
def _probe_duration(path: Path) -> float:
"""用 ffprobe 读视频时长(若系统装了 ffmpeg);否则返回 0."""
try:
import subprocess
out = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
],
stderr=subprocess.DEVNULL,
timeout=10,
)
return float(out.decode().strip() or 0)
except Exception:
return 0.0
def _upload_result(upload_url: str, file_path: Path) -> bool:
"""PUT 上传结果视频到预签名 URL."""
try:
with open(file_path, "rb") as f:
r = requests.put(
upload_url,
data=f,
headers={"Content-Type": "video/mp4"},
timeout=Config.request_timeout,
)
if r.status_code >= 400:
logger.error("上传结果失败 HTTP %d: %s", r.status_code, r.text[:500])
return False
return True
except Exception as exc:
logger.error("上传结果异常: %s", exc)
return False
def _report_result(task_id: str, success: bool, duration: float = 0.0, error_msg: str = "") -> bool:
"""通知服务端结果。失败时也尝试上报错误(不含视频文件)."""
try:
data = {
"task_id": task_id,
"worker_id": Config.derived_worker_id(),
"success": "true" if success else "false",
"duration_seconds": str(duration),
"error_msg": error_msg,
}
r = requests.post(
f"{Config.api_base_url}/api/v1/gpu/lipsync/result",
data=data,
headers=_api_headers(),
timeout=30,
)
if r.status_code != 200:
logger.error("上报结果失败 HTTP %d: %s", r.status_code, r.text[:300])
return False
return True
except Exception as exc:
logger.error("上报结果异常: %s", exc)
return False
class TaskHeartbeat(threading.Thread):
"""推理期间的任务心跳线程。
主循环的空闲心跳在 ``_handle_task`` 同步阻塞(下载/推理/上传最长 900s)
期间无法发送,服务端会因任务 last_heartbeat_at 停滞而误判超时回退 pending。
本线程每 task_heartbeat_interval 秒(默认 30sPOST /gpu/register 并
携带当前 task_id,让服务端持续续期任务心跳;任务处理结束 stop()。
"""
def __init__(self, task_id: str, interval: float):
super().__init__(daemon=True, name=f"hb-{task_id[:8]}")
self.task_id = task_id
self.interval = max(5.0, interval)
self._stop_event = threading.Event()
def run(self) -> None:
# 先立即发一次,再按间隔循环(首次心跳失败不影响主流程)
while not self._stop_event.is_set():
try:
if _register(self.task_id):
logger.debug("任务 %s 心跳已发送", self.task_id)
except Exception as exc: # noqa: BLE001
logger.warning("任务 %s 心跳异常(忽略): %s", self.task_id, exc)
self._stop_event.wait(self.interval)
def stop(self) -> None:
self._stop_event.set()
def _handle_task(task: dict) -> None:
"""处理一条任务(整个串行流程:下载→时长校验→推理→上传→上报)。"""
task_id = task["task_id"]
logger.info("开始处理任务 %s", task_id)
# 领取任务后立即启动任务级心跳线程,覆盖下载/推理/上报全过程
hb = TaskHeartbeat(task_id, Config.task_heartbeat_interval)
hb.start()
try:
with tempfile.TemporaryDirectory(prefix="musetalk_") as tmpdir:
tmp = Path(tmpdir)
video_path = tmp / "input.mp4"
audio_path = tmp / "input_audio.bin"
out_path = tmp / "output.mp4"
# 1. 下载
if not _download(task["video_url"], video_path):
_report_result(task_id, False, 0.0, "下载人物视频失败")
return
if not _download(task["audio_url"], audio_path):
_report_result(task_id, False, 0.0, "下载驱动音频失败")
return
# 2. 输入时长前置校验:短视频 MuseTalk 会 division by zero
# 直接上报 failed,不浪费 GPU 时间。ffprobe 不可用/读失败(0.0
# 时不拦截,交给 MuseTalk 处理,避免误杀。
video_duration = _probe_duration(video_path)
if video_duration and video_duration < Config.min_video_duration_seconds:
msg = (
f"视频过短({video_duration:.2f}s < {Config.min_video_duration_seconds:.0f}s),"
"MuseTalk 无法处理"
)
logger.error("任务 %s %s", task_id, msg)
_report_result(task_id, False, 0.0, msg)
return
# 3. 推理(本地仅对瞬时错误重试)
success = False
duration = 0.0
err = ""
retryable = False
for attempt in range(Config.task_max_retry + 1):
if attempt > 0:
logger.info("任务 %s%d 次重试(瞬时错误)...", task_id, attempt + 1)
time.sleep(2)
success, duration, err, retryable = _call_musetalk(video_path, audio_path, out_path)
if success or not retryable:
break
if not success:
logger.error("任务 %s 推理失败: %s", task_id, err)
_report_result(task_id, False, 0.0, err)
return
# 4. 上报结果(multipart 同时上传文件 → API 代为 PUT 到 OSS,逻辑最稳)
_report_success_with_file(task_id, duration, out_path)
finally:
hb.stop()
def _report_success_with_file(task_id: str, duration: float, file_path: Path) -> None:
"""上报成功并 multipart 附带结果视频."""
try:
data = {
"task_id": task_id,
"worker_id": Config.derived_worker_id(),
"success": "true",
"duration_seconds": str(duration),
"error_msg": "",
}
with open(file_path, "rb") as f:
files = {"result": (f"{task_id}.mp4", f, "video/mp4")}
r = requests.post(
f"{Config.api_base_url}/api/v1/gpu/lipsync/result",
data=data,
files=files,
headers=_api_headers(),
timeout=Config.request_timeout,
)
if r.status_code != 200:
logger.error("上报成功结果失败 HTTP %d: %s", r.status_code, r.text[:300])
return
logger.info("任务 %s 完成,duration=%.1fs", task_id, duration)
except Exception as exc:
logger.error("上报成功结果异常: %s", exc)
# ── 主循环 ──────────────────────────────────────────────────────────
def main() -> int:
logger.info("=" * 60)
logger.info("MuseTalk GPU Worker 启动")
logger.info(" worker_id = %s", Config.derived_worker_id())
logger.info(" api_base = %s", Config.api_base_url)
logger.info(" muse_talk = %s", Config.muse_talk_url)
logger.info(" poll = %.1fs / heartbeat = %.1fs", Config.poll_interval, Config.heartbeat_interval)
logger.info("=" * 60)
if not Config.gpu_worker_token:
logger.warning("GPU_WORKER_TOKEN 未配置(开发模式),生产环境必须设置")
# 先检查一次 MuseTalk
ok, info = _check_musetalk_health()
if ok:
logger.info("MuseTalk 健康检查通过: %s", info)
else:
logger.warning("MuseTalk 健康检查未通过: %s(继续运行,等待服务可用)", info)
# 启动时立即注册
_register()
last_heartbeat = time.time()
while True:
try:
# 心跳
now = time.time()
if now - last_heartbeat >= Config.heartbeat_interval:
if _register():
last_heartbeat = now
# 轮询任务
task = _poll_task()
if task is not None:
_handle_task(task)
# 处理完立即再 poll(不 sleep),尽可能拉满 GPU
continue
time.sleep(Config.poll_interval)
except KeyboardInterrupt:
logger.info("收到中断信号,退出")
return 0
except Exception as exc:
logger.exception("主循环异常: %s", exc)
time.sleep(Config.poll_interval)
if __name__ == "__main__":
sys.exit(main())
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=MuseTalk Inference API Server
After=network.target nvidia-persistenced.service
[Service]
Type=simple
User=ying
WorkingDirectory=/home/ying/projects/MuseTalk
Environment=PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
Environment=PATH=/home/ying/projects/MuseTalk/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/home/ying/projects/MuseTalk/venv/bin/python /home/ying/projects/MuseTalk/musetalk_server.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=musetalk-server
[Install]
WantedBy=multi-user.target
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
requests>=2.31.0
@@ -0,0 +1,47 @@
#!/bin/bash
REPO_API="https://git.xiaoxiajianji.com/api/v1/repos/xiaoxia/xiaoxia-saas/commits?sha=develop&path=deploy/gpu_worker&limit=1"
STATE_FILE="/home/ying/projects/gpu-webhook/.last_commit"
UPDATE_SCRIPT="/home/ying/projects/update-gpu-worker.sh"
LOG_FILE="$HOME/gpu-poll.log"
log() {
echo "[$(date +"%Y-%m-%d %H:%M:%S")] $*" >> "$LOG_FILE"
}
LATEST_SHA=$(curl -sk --max-time 10 "$REPO_API" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, list) and len(data) > 0:
print(data[0].get('sha', ''))
else:
print('')
except:
print('')
" 2>/dev/null)
if [ -z "$LATEST_SHA" ]; then
log "get latest commit failed, skip"
exit 0
fi
LAST_SHA=""
if [ -f "$STATE_FILE" ]; then
LAST_SHA=$(cat "$STATE_FILE")
fi
if [ "$LATEST_SHA" = "$LAST_SHA" ]; then
exit 0
fi
if [ -z "$LAST_SHA" ]; then
echo "$LATEST_SHA" > "$STATE_FILE"
log "first run, recording SHA: $LATEST_SHA"
exit 0
fi
log "new commit detected: $LAST_SHA -> $LATEST_SHA, triggering update"
echo "$LATEST_SHA" > "$STATE_FILE"
bash "$UPDATE_SCRIPT" >> "$LOG_FILE" 2>&1
log "update completed"
@@ -0,0 +1,63 @@
#!/bin/bash
# GPU节点一键初始化脚本 - 在全新GPU机器上执行
set -e
echo "=== 1. 安装系统依赖 ==="
sudo apt-get update -qq
sudo apt-get install -y -qq python3 python3-pip python3-venv ffmpeg wget curl git
echo "=== 2. 创建目录 ==="
mkdir -p ~/projects/MuseTalk ~/projects/gpu-webhook /opt/xiaoxia-gpu-worker
echo "=== 3. 安装nvidia-container-toolkit(如需要Docker==="
# 可选,当前不使用Docker,跳过
# distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
# curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
# curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
# sudo nvidia-ctk runtime configure --runtime=docker
# sudo systemctl restart docker
echo "=== 4. 拉取服务配置和脚本 ==="
REPO_URL="https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker"
wget -q -O /tmp/musetalk-worker.service "$REPO_URL/musetalk-worker.service"
wget -q -O /tmp/gpu-poll.service "$REPO_URL/gpu-poll.service"
wget -q -O /tmp/gpu-poll.timer "$REPO_URL/gpu-poll.timer"
wget -q -O ~/projects/update-gpu-worker.sh "$REPO_URL/scripts/update-gpu-worker.sh"
wget -q -O ~/projects/gpu-webhook/poll_and_update.sh "$REPO_URL/scripts/poll_and_update.sh"
chmod +x ~/projects/update-gpu-worker.sh ~/projects/gpu-webhook/poll_and_update.sh
echo "=== 5. 安装systemd服务 ==="
sudo cp /tmp/musetalk-worker.service /etc/systemd/system/
sudo cp /tmp/gpu-poll.service /etc/systemd/system/
sudo cp /tmp/gpu-poll.timer /etc/systemd/system/
echo "=== 6. 配置sudo免密 ==="
sudo bash -c 'cat > /etc/sudoers.d/ying-gpu-update << EOF
ying ALL=(ALL) NOPASSWD: /bin/systemctl restart musetalk-worker
ying ALL=(ALL) NOPASSWD: /bin/systemctl restart xiaoxia-gpu-worker
ying ALL=(ALL) NOPASSWD: /bin/systemctl status musetalk-worker
ying ALL=(ALL) NOPASSWD: /bin/systemctl status xiaoxia-gpu-worker
ying ALL=(ALL) NOPASSWD: /bin/systemctl daemon-reload
ying ALL=(ALL) NOPASSWD: /usr/bin/journalctl
ying ALL=(ALL) NOPASSWD: /bin/cp
ying ALL=(ALL) NOPASSWD: /bin/chmod
ying ALL=(ALL) NOPASSWD: /usr/bin/tee
EOF'
sudo chmod 440 /etc/sudoers.d/ying-gpu-update
echo "=== 7. 首次拉取代码并启动服务 ==="
bash ~/projects/update-gpu-worker.sh
sudo systemctl daemon-reload
sudo systemctl enable musetalk-worker xiaoxia-gpu-worker gpu-poll.timer
sudo systemctl start musetalk-worker xiaoxia-gpu-worker gpu-poll.timer
echo "=== 完成! ==="
echo "检查服务状态:"
echo " sudo systemctl status musetalk-worker"
echo " sudo systemctl status xiaoxia-gpu-worker"
echo " sudo systemctl status gpu-poll.timer"
echo "健康检查:curl http://127.0.0.1:7861/health"
echo "更新日志:tail -f ~/gpu-worker-update.log"
echo "轮询日志:tail -f ~/gpu-poll.log"
@@ -0,0 +1,62 @@
#!/bin/bash
set -e
REPO_URL="https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker"
MUSE_DIR="/home/ying/projects/MuseTalk"
WORKER_DIR="/opt/xiaoxia-gpu-worker"
LOG_FILE="$HOME/gpu-worker-update.log"
log() {
local NOW
NOW=$(date +"%Y-%m-%d %H:%M:%S")
echo "[$NOW] $*" | tee -a "$LOG_FILE"
}
log "========== start update =========="
BAK_SUFFIX=$(date +"%Y%m%d%H%M%S")
cp "$MUSE_DIR/musetalk_server.py" "$MUSE_DIR/musetalk_server.py.bak.$BAK_SUFFIX"
cp "$WORKER_DIR/gpu_worker.py" "$WORKER_DIR/gpu_worker.py.bak.$BAK_SUFFIX"
log "backup done ($BAK_SUFFIX)"
wget -q -O "$MUSE_DIR/musetalk_server.py" "$REPO_URL/musetalk_server.py"
log "musetalk_server.py updated"
wget -q -O "$WORKER_DIR/gpu_worker.py" "$REPO_URL/gpu_worker.py"
log "gpu_worker.py updated"
wget -q -O /tmp/gpu-requirements.txt "$REPO_URL/requirements.txt"
if [ -f "$WORKER_DIR/requirements.txt" ] && ! diff -q "$WORKER_DIR/requirements.txt" /tmp/gpu-requirements.txt > /dev/null 2>&1; then
log "requirements changed, updating..."
cp /tmp/gpu-requirements.txt "$WORKER_DIR/requirements.txt"
"$WORKER_DIR/venv/bin/pip" install -r "$WORKER_DIR/requirements.txt" -q
log "pip install done"
else
log "requirements no change, skip pip"
fi
sudo systemctl restart musetalk-worker
log "musetalk restarted"
sleep 5
sudo systemctl restart xiaoxia-gpu-worker
log "gpu-worker restarted"
sleep 8
HEALTH=$(curl -s http://127.0.0.1:7861/health 2>/dev/null)
if echo "$HEALTH" | grep -q "healthy\|ok"; then
log "health check OK"
log "========== update done =========="
exit 0
else
log "health check FAILED, rolling back..."
LATEST_MUSE_BAK=$(ls -t "$MUSE_DIR/musetalk_server.py.bak."* 2>/dev/null | head -1)
LATEST_WORKER_BAK=$(ls -t "$WORKER_DIR/gpu_worker.py.bak."* 2>/dev/null | head -1)
[ -n "$LATEST_MUSE_BAK" ] && cp "$LATEST_MUSE_BAK" "$MUSE_DIR/musetalk_server.py"
[ -n "$LATEST_WORKER_BAK" ] && cp "$LATEST_WORKER_BAK" "$WORKER_DIR/gpu_worker.py"
sudo systemctl restart musetalk-worker
sleep 5
sudo systemctl restart xiaoxia-gpu-worker
log "rolled back"
exit 1
fi
@@ -0,0 +1,21 @@
[Unit]
Description=MuseTalk GPU Worker (xiaoxia-saas 反向轮询)
After=network.target musetalk-worker.service
# 本地 MuseTalk 服务(musetalk-worker.service)启动后再启动本 Worker
[Service]
Type=simple
User=ying
WorkingDirectory=/opt/xiaoxia-gpu-worker
# 读取环境变量(API 地址、Token、轮询间隔等)
EnvironmentFile=/opt/xiaoxia-gpu-worker/.env
ExecStart=/opt/xiaoxia-gpu-worker/venv/bin/python /opt/xiaoxia-gpu-worker/gpu_worker.py
Restart=always
RestartSec=10
# 日志走 journal,用 journalctl -u xiaoxia-gpu-worker -f 查看
StandardOutput=journal
StandardError=journal
SyslogIdentifier=xiaoxia-gpu-worker
[Install]
WantedBy=multi-user.target
+6
View File
@@ -31,6 +31,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy
RUN mkdir -p /usr/share/fonts/truetype/xiaoxia
COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf
COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf
COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf
COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf
RUN fc-cache -fv
# 创建虚拟环境
+6
View File
@@ -35,6 +35,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy
RUN mkdir -p /usr/share/fonts/truetype/xiaoxia
COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf
COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf
COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf
COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf
RUN fc-cache -fv
# 创建虚拟环境
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -83,6 +83,31 @@ class SQLAlchemyAssetAtomClipRepository:
models = query.all()
return [self._to_domain(m) for m in models]
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
"""更新指定片段的 ai_tags 字段."""
count = (
self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update({"ai_tags": ai_tags})
)
self.session.commit()
return count > 0
def find_untagged(self, limit: int = 100, include_downgraded: bool = False) -> list[AssetAtomClip]:
"""查找未完成 AI 打标的片段,用于回填.
默认仅匹配 ai_tags IS NULLinclude_downgraded=True 时额外包含
只有 inherited_tags 的降级记录(视觉 API 失败时写入,无 has_text 字段),
供强制回填(#1970 force backfill)使用。
"""
query = self.session.query(AssetAtomClipModel)
if include_downgraded:
# as_string() → JSON/JSONB ->> 取值;NULL 记录或缺 has_text 键
# (降级记录)均为 NULLhas_text 为 true/false 的完整记录被排除
query = query.filter(AssetAtomClipModel.ai_tags["has_text"].as_string().is_(None))
else:
query = query.filter(AssetAtomClipModel.ai_tags.is_(None))
models = query.order_by(AssetAtomClipModel.created_at.asc()).limit(limit).all()
return [self._to_domain(m) for m in models]
def _to_model(self, clip: AssetAtomClip) -> AssetAtomClipModel:
return AssetAtomClipModel(
id=clip.id,
@@ -92,6 +117,7 @@ class SQLAlchemyAssetAtomClipRepository:
duration=clip.duration,
clip_index=clip.clip_index,
tags=clip.tags,
ai_tags=clip.ai_tags,
scene_change_at=clip.scene_change_at,
is_fallback=clip.is_fallback,
created_at=clip.created_at or datetime.now(UTC),

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