Compare commits

...

7 Commits

Author SHA1 Message Date
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
22 changed files with 1636 additions and 198 deletions
+16 -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) ====================
@@ -220,4 +223,13 @@ 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
+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:
@@ -325,3 +325,58 @@ class GpuLipsyncService:
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)
+160 -1
View File
@@ -36,6 +36,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 +64,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 预签名配置)."""
@@ -215,7 +217,52 @@ 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(job=job, gpu_svc=gpu_svc)
if gpu_task is not None:
# GPU 任务完成:直接把结果写入 job,标为 completed
job.mediakit_task_id = "" # GPU 路径不走 MediaKit
job.status = STATUS_COMPLETED
job.output_video_url = gpu_task.result_url
job.output_duration = gpu_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 MuseTalk 推理完成: job_id=%s gpu_task=%s duration=%.2f",
job.id,
gpu_task.id,
job.output_duration,
)
# output_video_url 已是 _submit_to_gpu 内签好的 7 天预签名 URL
return
# wait_for_result 返回 None 表示超时/最终失败 → 继续走 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 +291,118 @@ 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(self, *, job, gpu_svc) -> Optional[object]:
"""创建 GPU 任务并同步等待结果。
成功返回终态 task 对象(status=done);超时或 GPU 最终失败返回 None,
调用方回退 MediaKit。
输入处理:
- job.video_url 为用户上传视频,已在自家 OSS(裸 key 或自家 URL),
gpu_svc 在 poll 时签预签名 URL 给 Worker。
- job.audio_url 可能是预合成 TTS 的第三方临时地址(如
dashscope-result-bj.oss-cn-beijing.aliyuncs.com),Worker 家庭网络
拉不到;创建任务前先转存自家 OSS 再传入。
"""
storage = get_shared_storage_service()
# 外部音频(dashscope 临时链接等)先转存自家 OSS,避免 Worker 家庭网络拉取失败
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 任务
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,
)
# 同步等待 Worker 处理完成(轮询 DB)
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 None
if final_task.status != "done":
logger.warning(
"[lipsync] GPU 任务失败: gpu_task=%s status=%s err=%s",
gpu_task.id,
final_task.status,
final_task.error_msg,
)
return None
# result_url 是 OSS 存储 keygpu-lipsync/results/{task_id}.mp4,无 host
# _sign_media_url 对裸 key 不会签名);直接用 storage 签 7 天预签名 URL
# 写回 job.output_video_url,保证前端拿到可直接下载播放的地址
try:
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 结果视频签名失败,回退原始 result_url: gpu_task=%s err=%s",
gpu_task.id,
exc,
)
return final_task
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -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"
+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),
},
]
: []),
],
},
]
+2 -1
View File
@@ -44,7 +44,8 @@ export const createLipsyncJob = async (data: {
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
}
+29 -26
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"
@@ -437,19 +438,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 +524,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
/* ================================================================
渲染
+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>
)
}
+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({
+4
View File
@@ -259,3 +259,7 @@ 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
+4
View File
@@ -276,3 +276,7 @@ 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
+33 -2
View File
@@ -48,8 +48,33 @@ vim .env
| `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` |
| `MUSE_ENABLE_VIDEO_LOOP` | 驱动音频比视频长时循环视频补齐画面,`0` 关闭 | `1` |
### 2.2 启动服务
### 2.2 更新部署(音轨修复,必做)
> ⚠️ 2026-09-20 修复严重 bug:旧版封装保留了源视频音轨,结果口型配的是原声而不是 TTS 驱动音频。RTX2060 机器必须重新拉取 `musetalk_server.py` 并重启:
```bash
# 在 RTX2060 上备份旧文件并拉取新版本(按实际部署路径调整)
cp ~/projects/MuseTalk/musetalk_server.py ~/projects/MuseTalk/musetalk_server.py.bak
# 从仓库 raw 地址下载最新版(替换为你的仓库地址/分支)
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
```
修复后封装逻辑:
- 最终 mux 强制 `-map 0:v -map 1:a`:视频流只取 MuseTalk 无声画面,音轨只取 TTS 驱动音频,杜绝 ffmpeg 默认行为带入源视频音轨
- 驱动音频不长于视频时:`-c:v copy -c:a aac -shortest`,无损秒封装
- 驱动音频长于视频时(如 TTS 15s vs 视频 9s):`-stream_loop -1` 循环画面,RTX2060 走 `h264_nvenc` 硬件重编码(NVENC 失败自动回退 libx264),`-t` 精确卡到音频时长
### 2.3 启动服务
```bash
# 前台运行(调试用)
@@ -60,7 +85,7 @@ sudo systemctl start musetalk-server
sudo systemctl enable musetalk-server
```
### 2.3 验证健康检查
### 2.4 验证健康检查
```bash
curl http://127.0.0.1:7861/health
@@ -184,3 +209,9 @@ MuseTalk 健康检查通过: {...}
新增:
- `/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` 单任务覆盖
+185 -10
View File
@@ -11,6 +11,8 @@
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
MUSE_ENABLE_VIDEO_LOOP 驱动音频比视频长时是否循环视频补齐,默认 1(开启)
接口:
GET /health 健康检查 + GPU 显存信息
@@ -57,6 +59,12 @@ class Config:
audio_max_mb: int = int(_env("MUSE_AUDIO_MAX_MB", "20"))
default_fps: float = float(_env("MUSE_DEFAULT_FPS", "25.0"))
temp_dir: str = _env("MUSE_TEMP_DIR", f"/tmp/musetalk_{os.getpid()}")
# 循环视频时编码器:auto 优先 h264_nvencRTX2060 支持),失败兜底 libx264
video_encoder: str = _env("MUSE_VIDEO_ENCODER", "auto") or "auto"
# 驱动音频比视频长时循环视频补齐画面
enable_video_loop: bool = _env("MUSE_ENABLE_VIDEO_LOOP", "1") not in ("0", "false", "False", "")
# 判定音视频时长差异的容差(秒),避免 ffprobe 微小误差触发无谓的循环/重编码
duration_epsilon: float = 0.25
# ── 全局状态 ──────────────────────────────────────────────────────────
@@ -159,6 +167,152 @@ def _get_video_fps(video_path: Path) -> float:
return Config.default_fps
def _get_media_duration(path: Path) -> float:
"""用 ffprobe 读媒体时长(秒),失败返回 0.0."""
try:
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,
)
duration = float(out.decode().strip())
return duration if duration > 0 else 0.0
except Exception as exc:
logger.warning("ffprobe 读时长失败 %s: %s", path, exc)
return 0.0
def _pick_video_encoder() -> str:
"""选择视频编码器:配置指定则用指定值;auto 时探测 NVENC 是否可用,不可用回退 libx264."""
configured = Config.video_encoder.strip()
if configured in ("h264_nvenc", "libx264"):
return configured
# auto:探测本机 ffmpeg 是否编译了 h264_nvenc
try:
result = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=10,
check=False,
)
if b"h264_nvenc" in result.stdout:
return "h264_nvenc"
except Exception as exc:
logger.warning("探测 ffmpeg 编码器失败,回退 libx264: %s", exc)
return "libx264"
def _mux_video_with_audio(
video_path: Path,
audio_path: Path,
output_path: Path,
enable_video_loop: Optional[bool] = None,
timeout: float = 300,
) -> None:
"""把无声画面视频与驱动音频封装为最终结果.
关键正确性要求:必须用 -map 0:v -map 1:a 显式指定取第一个输入(推理画面)的
视频流和第二个输入(驱动音频 TTS)的音频流,禁止 ffmpeg 默认流选择行为
(否则会把源视频自带音轨带进结果,口型与声音错位)。
时长对齐:驱动音频比视频长时(TTS 15s vs 原视频 9s 很常见),用
-stream_loop -1 循环视频画面到音频长度(NVENC 硬件重编码),-t 卡到音频时长;
音频不超过视频时直接 -c:v copy 无损快封装,-shortest 以较短流为准。
"""
video_duration = _get_media_duration(video_path)
audio_duration = _get_media_duration(audio_path)
loop_enabled = Config.enable_video_loop if enable_video_loop is None else enable_video_loop
need_loop = bool(
loop_enabled
and audio_duration > 0
and video_duration > 0
and audio_duration > video_duration + Config.duration_epsilon
)
if need_loop:
encoder = _pick_video_encoder()
# preset 随编码器选择:h264_nvenc 用 p1-p7libx264 用词形 preset
preset = "p4" if encoder == "h264_nvenc" else "veryfast"
logger.info(
"音频(%.2fs)长于视频(%.2fs),循环视频并以 %s(%s) 重编码至音频长度",
audio_duration,
video_duration,
encoder,
preset,
)
def build_cmd(enc: str, pre: str) -> list:
return [
"ffmpeg",
"-y",
"-stream_loop",
"-1",
"-i",
str(video_path),
"-i",
str(audio_path),
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
enc,
"-preset",
pre,
"-c:a",
"aac",
"-b:a",
"128k",
"-t",
f"{audio_duration:.3f}",
str(output_path),
]
try:
_run_ffmpeg(build_cmd(encoder, preset), timeout=timeout)
except RuntimeError:
# NVENC 可能因驱动/占用失败,兜底 libx264 重试一次
if encoder == "h264_nvenc":
logger.warning("h264_nvenc 封装失败,回退 libx264 重试")
_run_ffmpeg(build_cmd("libx264", "veryfast"), timeout=timeout)
else:
raise
else:
# 视频不短于音频:直接复制视频流,只把音频替换为驱动音频并转 AAC
cmd = [
"ffmpeg",
"-y",
"-i",
str(video_path),
"-i",
str(audio_path),
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
str(output_path),
]
_run_ffmpeg(cmd, timeout=timeout)
def _check_file_size(file, max_mb: int, label: str) -> Optional[str]:
"""检查文件大小,超限返回错误信息,否则返回 None."""
file.seek(0, 2)
@@ -190,11 +344,18 @@ def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess:
raise RuntimeError(f"ffmpeg 超时(>{timeout}s") from exc
def _run_inference(video_path: Path, audio_path: Path, output_path: Path) -> None:
def _run_inference(
video_path: Path,
audio_path: Path,
output_path: Path,
enable_video_loop: Optional[bool] = None,
) -> None:
"""执行 MuseTalk 推理(可被子线程和测试独立调用).
实际部署时替换为 MuseTalk 真实推理逻辑。
此处为示例实现:提取帧 → 合并音视频
此处为示例实现:提取帧 → 生成无声画面 → 用驱动音频封装
enable_video_loop: 驱动音频长于视频时是否循环视频;None 走全局配置。
"""
fps = _get_video_fps(video_path)
logger.info("视频 fps: %.2f", fps)
@@ -218,27 +379,32 @@ def _run_inference(video_path: Path, audio_path: Path, output_path: Path) -> Non
if not frame_files:
raise RuntimeError("未从视频中提取到帧")
# TODO: 替换为 MuseTalk 实际推理逻辑
# TODO: 替换为 MuseTalk 实际推理逻辑
# MuseTalk 真实产物是「无声画面视频」,音轨必须在封装阶段用驱动音频替换。
logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型")
# 示例:从源视频生成无声画面(-an 丢弃原音轨),模拟 MuseTalk 推理产物。
# 真实部署时 silent_video_path 应替换为 MuseTalk 输出的无声视频路径。
silent_video_path = video_path.parent / "visual_silent.mp4"
_run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(video_path),
"-i",
str(audio_path),
"-an",
"-c:v",
"libx264",
"-c:a",
"aac",
"-shortest",
str(output_path),
"-preset",
"veryfast",
str(silent_video_path),
],
timeout=300,
)
# 统一封装:显式 -map 取推理画面 + 驱动音频;音频更长时循环视频。
_mux_video_with_audio(silent_video_path, audio_path, output_path, enable_video_loop=enable_video_loop)
if not output_path.exists() or output_path.stat().st_size < 1024:
raise RuntimeError("推理产物不存在或过小")
@@ -286,6 +452,13 @@ def inference():
audio_file = request.files["audio"]
task_id = request.form.get("task_id", f"task_{int(time.time())}")
# 可选:本次任务是否在音频长于视频时循环视频(缺省走全局配置)
loop_param = request.form.get("enable_video_loop")
if loop_param is not None:
task_enable_loop = loop_param.strip() not in ("0", "false", "False", "")
else:
task_enable_loop = None
# 文件大小检查
err = _check_file_size(video_file, Config.video_max_mb, "视频")
if err:
@@ -319,7 +492,7 @@ def inference():
def inference_thread():
try:
_run_inference(video_path, audio_path, output_path)
_run_inference(video_path, audio_path, output_path, enable_video_loop=task_enable_loop)
except Exception as exc:
result_container["error"] = str(exc)
@@ -413,6 +586,8 @@ def main():
logger.info(" 视频大小限制: %dMB", Config.video_max_mb)
logger.info(" 音频大小限制: %dMB", Config.audio_max_mb)
logger.info(" 默认 fps: %.1f", Config.default_fps)
logger.info(" 视频编码器: %s", Config.video_encoder)
logger.info(" 音频长于视频时循环视频: %s", Config.enable_video_loop)
logger.info("=" * 60)
# 检查 GPU
+37 -3
View File
@@ -8,6 +8,7 @@ API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段
import os
from typing import Optional, TypeVar
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T", bound=BaseSettings)
@@ -76,9 +77,33 @@ class SharedSettings(BaseSettings):
mediakit_timeout: int = 60
# ── 积分/会员系统 (#1895) ────────────────────────────────────────────
# 总开关:默认 false(对所有用户零影响),P2 路由逐个接入时用
# `if settings.points_enabled:` 包裹,防止未完善的扣点逻辑影响现有用户。
points_enabled: bool = False
# 积分系统总开关(产品要求 #1895:暂停积分系统但保留全部代码/表/接口)。
# - false(默认):所有 AI 功能(生成视频/口型/数字人/AI标题/TTS/克隆音色…)
# 对全部登录用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员
# 状态等查询接口保持可用,但数据不再变动。
# - 未来恢复:只需设置环境变量 ENABLE_CREDIT_SYSTEM=true。
# 旧开关 POINTS_ENABLED 仍保留作为兼容别名(两者任一为 true 即启用)。
# 主开关(推荐环境变量名 ENABLE_CREDIT_SYSTEM
credits_enabled: bool = Field(
default=False,
validation_alias=AliasChoices("ENABLE_CREDIT_SYSTEM", "credits_enabled"),
)
# 旧开关兼容(POINTS_ENABLED);两者任一为 true 即启用
points_enabled_compat: bool = Field(
default=False,
validation_alias=AliasChoices("POINTS_ENABLED", "points_enabled_compat"),
)
@property
def points_enabled(self) -> bool:
"""旧代码/测试使用的属性名,等价于积分系统总开关(兼容别名)。"""
return bool(self.credits_enabled or self.points_enabled_compat)
@points_enabled.setter
def points_enabled(self, value: bool) -> None:
# 支持旧测试/代码 ``settings.points_enabled = True`` 的写法
self.credits_enabled = bool(value)
self.points_enabled_compat = False
# ── GPU MuseTalk 反向轮询 Worker ────────────────────────────────────
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token
@@ -93,6 +118,15 @@ class SharedSettings(BaseSettings):
gpu_result_url_expires: int = 3600
# 输入预签名 URL 有效期(秒,需留出 Worker 下载时间)
gpu_input_url_expires: int = 3600
# 业务侧是否启用 GPU 口型同步(开关);关或无可用 Worker 时回退 MediaKit 云端
use_gpu_lipsync: bool = False
# 业务侧轮询 GPU 任务结果的间隔(秒)
gpu_lipsync_poll_interval: float = 5.0
# 业务侧等待 GPU 任务结果的总超时(秒);超时后回退 MediaKit。
# 应小于等于 gpu_task_timeout_seconds(默认900s+ 冗余,留足 Worker 下载/上传时间。
gpu_lipsync_wait_timeout: int = 1200
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
gpu_worker_stale_seconds: int = 300
@property
def effective_database_url(self) -> str:
+374
View File
@@ -0,0 +1,374 @@
"""#1978 MuseTalk 服务端音轨替换 + 视频循环修复单测.
覆盖 deploy/gpu_worker/musetalk_server.py
1. 最终封装必须 -map 0:v -map 1:a 取「推理画面 + 驱动音频」,禁止默认流选择带入源视频音轨
2. 音频不超过视频:-c:v copy + -shortest 快速封装
3. 音频长于视频:-stream_loop -1 循环视频,NVENC/libx264 重编码,-t 卡到音频时长
4. enable_video_loop=false 时即使音频更长也不循环
5. h264_nvenc 失败自动回退 libx264
6. 真实 ffmpeg 端到端:源视频内置 200Hz 音轨 + 驱动音频 800Hz,结果音轨必须是 800Hz
(过零率估计),证明音轨来自第二个输入而非源视频;音频更长时输出时长对齐音频
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest import mock
import pytest
try:
import flask # noqa: F401
HAS_FLASK = True
except ImportError:
HAS_FLASK = False
pytestmark = pytest.mark.skipif(not HAS_FLASK, reason="Flask 未安装(gpu_worker 独立部署依赖)")
ROOT = Path(__file__).resolve().parents[2]
SERVER_PATH = ROOT / "deploy" / "gpu_worker" / "musetalk_server.py"
HAS_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
def _load_server(name: str):
if name in sys.modules:
del sys.modules[name]
spec = importlib.util.spec_from_file_location(name, SERVER_PATH)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def server(tmp_path, monkeypatch):
if not HAS_FLASK:
pytest.skip("Flask 未安装")
monkeypatch.setenv("MUSE_TEMP_DIR", str(tmp_path / "musetalk_temp"))
monkeypatch.setenv("MUSE_VIDEO_ENCODER", "libx264")
mod = _load_server(f"musetalk_mux_{os.getpid()}_{id(tmp_path)}")
mod.Config.video_encoder = "libx264"
mod.Config.enable_video_loop = True
return mod
# ── 命令构造:非循环路径 ─────────────────────────────────────────────
def test_mux_non_loop_maps_video_and_drives_audio(server, tmp_path):
"""音频(5s)不长于视频(10s):显式 map 0:v/1:a,视频流 copy-shortest."""
video = tmp_path / "visual.mp4"
audio = tmp_path / "tts.mp3"
video.write_bytes(b"v")
audio.write_bytes(b"a")
captured = {}
def fake_run(cmd, timeout=300):
captured["cmd"] = cmd
with (
mock.patch.object(server, "_get_media_duration", side_effect=[10.0, 5.0]),
mock.patch.object(server, "_run_ffmpeg", side_effect=fake_run),
):
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
cmd = captured["cmd"]
# 输入顺序:0=无声画面,1=驱动音频
assert cmd.index(str(video)) < cmd.index(str(audio))
# 关键修复:强制流映射,不能让 ffmpeg 默认选择源视频音轨
assert "-map" in cmd
assert "0:v:0" in cmd
assert "1:a:0" in cmd
assert "-c:v" in cmd and cmd[cmd.index("-c:v") + 1] == "copy"
assert "-shortest" in cmd
# 非循环不重编码
assert "-stream_loop" not in cmd
assert "-t" not in cmd
def test_mux_non_loop_duration_epsilon(server, tmp_path):
"""音频略长于视频但在容差内(0.25s)不触发循环重编码."""
video = tmp_path / "visual.mp4"
audio = tmp_path / "tts.mp3"
video.write_bytes(b"v")
audio.write_bytes(b"a")
captured = {}
with (
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 9.1]),
mock.patch.object(server, "_run_ffmpeg", side_effect=lambda cmd, timeout=300: captured.update(cmd=cmd)),
):
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
assert "-stream_loop" not in captured["cmd"]
# ── 命令构造:循环路径 ───────────────────────────────────────────────
def test_mux_loop_when_audio_longer_uses_stream_loop_and_nvenc(server, tmp_path):
"""音频(15s)长于视频(9s)-stream_loop -1 循环、NVENC 重编码、-t 音频时长."""
video = tmp_path / "visual.mp4"
audio = tmp_path / "tts.mp3"
video.write_bytes(b"v")
audio.write_bytes(b"a")
server.Config.video_encoder = "h264_nvenc"
captured = {}
with (
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 15.0]),
mock.patch.object(server, "_run_ffmpeg", side_effect=lambda cmd, timeout=300: captured.update(cmd=cmd)),
):
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
cmd = captured["cmd"]
# -stream_loop 必须位于第一个 -i 之前
assert "-stream_loop" in cmd
sl_idx = cmd.index("-stream_loop")
assert cmd[sl_idx + 1] == "-1"
assert sl_idx < cmd.index("-i")
# 同样必须显式 map
assert "0:v:0" in cmd and "1:a:0" in cmd
assert cmd[cmd.index("-c:v") + 1] == "h264_nvenc"
# -t 卡到音频时长,且不用 -shortest(避免截短音频)
assert "-shortest" not in cmd
t_idx = cmd.index("-t")
assert abs(float(cmd[t_idx + 1]) - 15.0) < 0.01
def test_mux_loop_disabled_falls_back_to_copy(server, tmp_path):
"""enable_video_loop=False:即使音频更长也不循环,走 copy+shortest."""
video = tmp_path / "visual.mp4"
audio = tmp_path / "tts.mp3"
video.write_bytes(b"v")
audio.write_bytes(b"a")
captured = {}
with (
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 15.0]),
mock.patch.object(server, "_run_ffmpeg", side_effect=lambda cmd, timeout=300: captured.update(cmd=cmd)),
):
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4", enable_video_loop=False)
assert "-stream_loop" not in captured["cmd"]
assert captured["cmd"][captured["cmd"].index("-c:v") + 1] == "copy"
def test_mux_nvenc_failure_falls_back_to_libx264(server, tmp_path):
"""NVENC 调用失败时自动用 libx264 重试一次."""
video = tmp_path / "visual.mp4"
audio = tmp_path / "tts.mp3"
video.write_bytes(b"v")
audio.write_bytes(b"a")
server.Config.video_encoder = "h264_nvenc"
cmds = []
def runner(cmd, timeout=300):
cmds.append(list(cmd))
if cmd[cmd.index("-c:v") + 1] == "h264_nvenc":
raise RuntimeError("ffmpeg 失败 (code=1): Cannot load nvcuda")
with (
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 15.0]),
mock.patch.object(server, "_run_ffmpeg", side_effect=runner),
):
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
assert len(cmds) == 2
assert cmds[0][cmds[0].index("-c:v") + 1] == "h264_nvenc"
second = cmds[1]
assert second[second.index("-c:v") + 1] == "libx264"
# nvenc 的 preset p4 已替换为 x264 兼容值
assert "p4" not in second
assert "0:v:0" in second and "1:a:0" in second
def test_mux_copy_failure_propagates(server, tmp_path):
"""非循环路径 ffmpeg 失败应抛出(不静默吞错)."""
video = tmp_path / "visual.mp4"
audio = tmp_path / "tts.mp3"
video.write_bytes(b"v")
audio.write_bytes(b"a")
with (
mock.patch.object(server, "_get_media_duration", side_effect=[10.0, 5.0]),
mock.patch.object(server, "_run_ffmpeg", side_effect=RuntimeError("ffmpeg 失败")),
):
with pytest.raises(RuntimeError):
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
def test_pick_video_encoder_respects_config(server):
"""显式配置的编码器优先,auto 时探测."""
server.Config.video_encoder = "libx264"
assert server._pick_video_encoder() == "libx264"
server.Config.video_encoder = "h264_nvenc"
assert server._pick_video_encoder() == "h264_nvenc"
def test_pick_video_encoder_auto_detects_nvenc(server):
"""auto 模式:ffmpeg -encoders 含 h264_nvenc 则选它."""
server.Config.video_encoder = "auto"
completed = subprocess.CompletedProcess(args=["ffmpeg"], returncode=0, stdout=b"... h264_nvenc ...", stderr=b"")
with mock.patch("subprocess.run", return_value=completed):
assert server._pick_video_encoder() == "h264_nvenc"
# ── 真实 ffmpeg 端到端:音轨来源与时长对齐 ────────────────────────────
@pytest.mark.skipif(not HAS_FFMPEG, reason="环境无 ffmpeg/ffprobe")
def _make_media(tmp_path: Path):
"""生成:带 200Hz 音轨的 2s 源视频 + 800Hz 的 5s 驱动音频."""
source_video = tmp_path / "source.mp4"
drive_audio = tmp_path / "drive.wav"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"testsrc=duration=2:size=160x120:rate=25",
"-f",
"lavfi",
"-i",
"sine=frequency=200:duration=2",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
str(source_video),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=800:duration=5",
str(drive_audio),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
return source_video, drive_audio
def _probe_duration(path: Path) -> float:
out = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
]
)
return float(out.decode().strip())
def _estimate_audio_freq(path: Path, duration: float) -> float:
"""解码为 8kHz 单声道 s16 PCM,用过零率估计主频."""
raw = subprocess.check_output(
[
"ffmpeg",
"-i",
str(path),
"-vn",
"-ac",
"1",
"-ar",
"8000",
"-f",
"s16le",
"-",
],
stderr=subprocess.DEVNULL,
)
import array
samples = array.array("h")
samples.frombytes(raw)
if len(samples) < 100:
return 0.0
crossings = sum(1 for i in range(1, len(samples)) if (samples[i - 1] < 0) != (samples[i] < 0))
secs = len(samples) / 8000
return crossings / 2.0 / secs
@pytest.mark.skipif(not HAS_FFMPEG, reason="环境无 ffmpeg/ffprobe")
def test_real_mux_replaces_source_audio_with_drive_audio(server, tmp_path):
"""端到端:结果音轨必须是驱动音频 800Hz,而不是源视频的 200Hz."""
source_video, drive_audio = _make_media(tmp_path)
# 模拟 MuseTalk 无声画面产物
silent_video = tmp_path / "visual_silent.mp4"
subprocess.run(
[
"ffmpeg",
"-y",
"-i",
str(source_video),
"-an",
"-c:v",
"libx264",
"-preset",
"ultrafast",
str(silent_video),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
output = tmp_path / "output.mp4"
server._mux_video_with_audio(silent_video, drive_audio, output)
assert output.exists() and output.stat().st_size > 1024
# 驱动音频 5s 长于画面 2s → 输出应接近 5s(循环补齐)
out_duration = _probe_duration(output)
assert abs(out_duration - 5.0) < 0.5, f"输出时长 {out_duration} 未对齐驱动音频"
# 结果音轨主频应接近 800Hz(驱动音频),远离 200Hz(源视频音轨)
freq = _estimate_audio_freq(output, out_duration)
assert abs(freq - 800) < abs(freq - 200), f"结果音轨主频 {freq:.0f}Hz 不是驱动音频"
assert freq > 450, f"结果音轨主频 {freq:.0f}Hz 疑似源视频音轨(200Hz)"
@pytest.mark.skipif(not HAS_FFMPEG, reason="环境无 ffmpeg/ffprobe")
def test_real_mux_non_loop_keeps_video_copy_path(server, tmp_path):
"""驱动音频(1s)短于视频(2s):输出约 1s,音轨仍是驱动音频."""
source_video, _ = _make_media(tmp_path)
short_audio = tmp_path / "short.wav"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=800:duration=1",
str(short_audio),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
output = tmp_path / "output_short.mp4"
server._mux_video_with_audio(source_video, short_audio, output)
out_duration = _probe_duration(output)
assert abs(out_duration - 1.0) < 0.4
freq = _estimate_audio_freq(output, out_duration)
assert freq > 450, f"结果音轨主频 {freq:.0f}Hz 疑似源视频音轨"
+229
View File
@@ -0,0 +1,229 @@
"""积分系统暂停开关测试 (#1895, ENABLE_CREDIT_SYSTEM)。
产品要求:暂停积分系统但保留全部代码/表/接口。
- 默认 false:所有 AI 功能免费放行,不扣积分、不做余额拦截;
- /points/check 恒返回 allowed=True、required_points=0
- /points/deduct 为 no-op,余额不变;
- 查询接口(balance/transactions/rules/packages/membership/usage)照常可用;
- 旧环境变量 POINTS_ENABLED 作为兼容别名仍可开启。
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
def _make_cu(user_id="user-1", is_member=False, member_type=None):
cu = MagicMock()
cu.user.id = user_id
cu.user.is_member = is_member
cu.user.member_type = member_type
cu.user.member_expires_at = None
return cu
# ── 配置层 ────────────────────────────────────────────────────────────────
class TestCreditSystemConfig:
def test_default_disabled(self):
import os
from packages.config.base import SharedSettings
assert os.environ.get("ENABLE_CREDIT_SYSTEM") is None
assert os.environ.get("POINTS_ENABLED") is None
s = SharedSettings(_env_file=None)
assert s.credits_enabled is False
# 旧属性名仍可用(业务代码大量引用 settings.points_enabled
assert s.points_enabled is False
def test_enable_credit_system_env(self, monkeypatch):
from packages.config import base as base_mod
monkeypatch.setenv("ENABLE_CREDIT_SYSTEM", "true")
s = base_mod.SharedSettings(_env_file=None)
assert s.points_enabled is True
assert s.credits_enabled is True
def test_legacy_points_enabled_env_alias(self, monkeypatch):
from packages.config import base as base_mod
monkeypatch.setenv("ENABLE_CREDIT_SYSTEM", "false")
monkeypatch.setenv("POINTS_ENABLED", "true")
s = base_mod.SharedSettings(_env_file=None)
assert s.points_enabled is True
assert s.credits_enabled is False
assert s.points_enabled_compat is True
def test_legacy_setter_back_compat(self):
from packages.config.base import SharedSettings
s = SharedSettings(_env_file=None)
s.points_enabled = True
assert s.credits_enabled is True
assert s.points_enabled is True
s.points_enabled = False
assert s.points_enabled is False
# ── /points/check:关闭时恒放行、需 0 积分 ────────────────────────────────
class TestCheckEndpointWhenDisabled:
def test_check_allowed_zero_required(self):
from app.api.routes.points import check_points
from app.schemas.points import PointsCheckRequest
svc = MagicMock()
svc.get_or_create_account.return_value = {"balance": 0}
db = MagicMock()
cu = _make_cu()
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=5)
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = check_points(body=body, current_user=cu, db=db)
assert resp.allowed is True
assert resp.required_points == 0
assert resp.remaining_after == 0
# 不再走免费额度判定
svc.check_daily_free_clip.assert_not_called()
def test_unknown_scene_still_400_when_disabled(self):
"""未知 scene 即使系统关闭也返回 400(参数校验先于开关)。"""
from app.api.routes.points import check_points
from app.schemas.points import PointsCheckRequest
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
check_points(body=PointsCheckRequest(scene_key="nope"), current_user=_make_cu(), db=MagicMock())
assert exc.value.status_code == 400
def test_check_enabled_calculates_cost(self):
"""开关开启时保持原有计费校验。"""
from app.api.routes.points import check_points
from app.schemas.points import PointsCheckRequest
svc = MagicMock()
svc.check_daily_free_clip.return_value = False
svc.get_or_create_account.return_value = {"balance": 100}
body = PointsCheckRequest(scene_key="ai_title", quantity=1)
with (
patch("app.api.routes.points._credits_enabled", return_value=True),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = check_points(body=body, current_user=_make_cu(), db=MagicMock())
assert resp.required_points == 2 # 免费用户 ceil(1*1.15)=2
# ── /points/deduct:关闭时 no-op,余额不变 ────────────────────────────────
class TestDeductEndpointWhenDisabled:
def test_deduct_is_noop(self):
from app.api.routes.points import deduct_points
from app.schemas.points import PointsDeductRequest
svc = MagicMock()
svc.get_or_create_account.return_value = {"balance": 7}
body = PointsDeductRequest(scene_key="ai_voice", amount=999)
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = deduct_points(body=body, current_user=_make_cu(), db=MagicMock())
svc.deduct_points.assert_not_called()
assert resp.success is True
assert resp.data["balance"] == 7
assert resp.data["transaction_id"] == ""
def test_deduct_enabled_works_as_before(self):
from app.api.routes.points import deduct_points
from app.schemas.points import PointsDeductRequest
svc = MagicMock()
svc.deduct_points.return_value = {"success": True, "balance": 8, "transaction_id": "tx-1"}
body = PointsDeductRequest(scene_key="ai_title", amount=2)
with (
patch("app.api.routes.points._credits_enabled", return_value=True),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = deduct_points(body=body, current_user=_make_cu(), db=MagicMock())
svc.deduct_points.assert_called_once()
assert resp.data["balance"] == 8
assert resp.data["transaction_id"] == "tx-1"
# ── 查询接口:系统关闭时仍全部可用 ────────────────────────────────────────
class TestQueryEndpointsRemainAvailable:
def test_balance_route_works_when_disabled(self):
from app.api.routes.points import get_balance
svc = MagicMock()
svc.get_or_create_account.return_value = {"balance": 0, "total_earned": 0, "total_spent": 0}
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = get_balance(current_user=_make_cu(), db=MagicMock())
assert resp.balance == 0
assert resp.is_member is False
def test_transactions_route_works_when_disabled(self):
from app.api.routes.points import get_transactions
svc = MagicMock()
svc.get_transactions.return_value = {"items": [], "total": 0, "page": 1, "page_size": 20}
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = get_transactions(current_user=_make_cu(), db=MagicMock())
assert resp.total == 0
def test_daily_usage_route_works_when_disabled(self):
from app.api.routes.points import get_daily_usage
svc = MagicMock()
svc.get_daily_usage.return_value = {
"free_clips_used": 0,
"free_clips_limit": 2,
"free_clips_remaining": 2,
"reset_at": "2026-09-20T00:00:00Z",
}
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = get_daily_usage(current_user=_make_cu(), db=MagicMock())
assert resp.free_clips_limit == 2
# ── 业务路由:开关关闭时 PointsService 不实例化、不扣分 ───────────────────
class TestBusinessRoutesBypassWhenDisabled:
def test_lipsync_route_skips_points(self):
"""lipsync 创建任务路由:settings.points_enabled=False 时不构造 PointsService。"""
from app.api.routes import lipsync as lipsync_mod
assert bool(getattr(lipsync_mod.settings, "points_enabled", False)) is False
def test_tts_route_skips_points(self):
from app.api.routes import tts as tts_mod
assert bool(getattr(tts_mod.settings, "points_enabled", False)) is False
+244
View File
@@ -0,0 +1,244 @@
"""LipsyncService GPU 路径集成测试."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def fake_db():
db = MagicMock()
return db
@pytest.fixture()
def fake_mediakit():
client = MagicMock()
client.submit_lipsync.return_value = {"task_id": "mk-task-1"}
return client
def _make_job(video_url="videos/video.mp4", audio_url="audios/audio.wav"):
job = MagicMock()
job.id = "job-1"
job.user_id = "u1"
job.project_id = "p1"
job.video_url = video_url
job.audio_url = audio_url
job.enable_video_loop = True
job.script_text = ""
job.sentence_timings = None
return job
def _make_svc(db, mediakit, use_gpu=False):
from app.services.lipsync_service import LipsyncService
svc = LipsyncService(db=db, client=mediakit)
svc.settings.use_gpu_lipsync = use_gpu
svc._sign_media_url = lambda u: (u or "") + "?signed"
return svc
def _patch_storage(public_url="https://own-bucket.oss-cn-beijing.aliyuncs.com", signed_suffix="?signed-7d"):
"""patch get_shared_storage_service,返回自家 OSS storage mock."""
storage = MagicMock()
storage.public_url = public_url
storage.get_download_url.side_effect = lambda key_or_url, expires_seconds=3600: key_or_url + signed_suffix
return patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage)
class TestGpuFallback:
def test_switch_off_uses_mediakit(self, fake_db, fake_mediakit):
"""开关关闭时直接走 MediaKit,不调用 _submit_to_gpu."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
job = _make_job()
with patch.object(svc, "_submit_to_gpu") as m_sub:
svc._submit_audio_direct(job=job)
m_sub.assert_not_called()
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_switch_on_no_worker_falls_back(self, fake_db, fake_mediakit):
"""开关打开但 has_available_worker=False → 回退 MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = False
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
job = _make_job()
svc._submit_audio_direct(job=job)
fake_gpu_svc.create_task.assert_not_called()
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_gpu_success_marks_completed(self, fake_db, fake_mediakit):
"""GPU 路径成功:job 直接 completed,不调 MediaKit;结果 key 由 storage 签 7 天 URL."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
gpu_done = MagicMock(
id="gpu-task-1",
status="done",
result_url="gpu-lipsync/results/gpu-task-1.mp4",
result_duration=12.5,
)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-1")
fake_gpu_svc.wait_for_result.return_value = gpu_done
with (
_patch_storage() as storage_p,
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
):
storage = storage_p()
job = _make_job()
svc._submit_audio_direct(job=job)
fake_gpu_svc.create_task.assert_called_once()
fake_mediakit.submit_lipsync.assert_not_called()
assert job.status == "completed"
assert job.output_duration == 12.5
# Bug1 回归:裸 result key 必须经 storage.get_download_url 签 7 天,前端才可播放
storage.get_download_url.assert_called_once_with(
"gpu-lipsync/results/gpu-task-1.mp4", expires_seconds=7 * 24 * 3600
)
assert job.output_video_url == "gpu-lipsync/results/gpu-task-1.mp4?signed-7d"
fake_db.commit.assert_called()
def test_gpu_timeout_falls_back(self, fake_db, fake_mediakit):
"""wait_for_result 返回 None(超时)→ 回退 MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
fake_gpu_svc.wait_for_result.return_value = None
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
job = _make_job()
svc._submit_audio_direct(job=job)
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_gpu_failed_status_falls_back(self, fake_db, fake_mediakit):
"""GPU 终态 failed → 回退 MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
fake_gpu_svc.wait_for_result.return_value = MagicMock(status="failed", error_msg="musetalk crash")
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
job = _make_job()
svc._submit_audio_direct(job=job)
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_gpu_exception_falls_back(self, fake_db, fake_mediakit):
"""GPU 路径抛异常 → 回退 MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.side_effect = RuntimeError("DB down")
with _patch_storage(), patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
job = _make_job()
svc._submit_audio_direct(job=job)
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_gpu_external_audio_persisted_to_own_oss(self, fake_db, fake_mediakit):
"""Bug2 回归:dashscope 临时音频 URL 在创建 GPU 任务前转存自家 OSS。"""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
dashscope_url = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/tmp/abc.mp3"
job = _make_job(audio_url=dashscope_url)
gpu_done = MagicMock(id="gpu-task-2", status="done", result_url="gpu-lipsync/results/gpu-task-2.mp4")
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-2")
fake_gpu_svc.wait_for_result.return_value = gpu_done
with (
_patch_storage() as storage_p,
patch("app.services.lipsync_service.safe_download_bytes", return_value=b"FAKE-MP3") as m_dl,
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
):
storage = storage_p()
storage.upload_file.return_value = "https://own-bucket.oss-cn-beijing.aliyuncs.com/lipsync-tts/u1/job-1.mp3"
svc._submit_audio_direct(job=job)
# 外部音频在 GPU 分支被额外下载(purpose 区分于前置 ffprobe 下载)并转存到约定 key
gpu_dl_calls = [c for c in m_dl.call_args_list if c.kwargs.get("purpose") == "lipsync_gpu_tts_audio"]
assert len(gpu_dl_calls) == 1
assert gpu_dl_calls[0].args[0] == dashscope_url
storage.upload_file.assert_called_once()
args, kwargs = storage.upload_file.call_args
assert args[1] == "lipsync-tts/u1/job-1.mp3"
assert kwargs.get("content_type") == "audio/mpeg"
# 创建 GPU 任务时用的是自家 OSS URL,Worker 可经预签名下载
kwargs_create = fake_gpu_svc.create_task.call_args.kwargs
assert kwargs_create["audio_url"] == ("https://own-bucket.oss-cn-beijing.aliyuncs.com/lipsync-tts/u1/job-1.mp3")
assert kwargs_create["audio_url"] != dashscope_url
def test_gpu_own_audio_not_repersisted(self, fake_db, fake_mediakit):
"""Bug2:已是自家 OSS 的音频(含裸 key)不重复下载转存。"""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
job = _make_job(audio_url="lipsync-tts/u1/job-1.mp3")
gpu_done = MagicMock(id="gpu-task-3", status="done", result_url="gpu-lipsync/results/gpu-task-3.mp4")
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-3")
fake_gpu_svc.wait_for_result.return_value = gpu_done
with (
_patch_storage() as storage_p,
patch("app.services.lipsync_service.safe_download_bytes") as m_dl,
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
):
storage = storage_p()
svc._submit_audio_direct(job=job)
# 前置 ffprobe 下载允许发生,但 GPU 转存分支不应再下载/上传
gpu_dl_calls = [c for c in m_dl.call_args_list if c.kwargs.get("purpose") == "lipsync_gpu_tts_audio"]
assert gpu_dl_calls == []
storage.upload_file.assert_not_called()
assert fake_gpu_svc.create_task.call_args.kwargs["audio_url"] == "lipsync-tts/u1/job-1.mp3"
def test_gpu_external_audio_persist_fail_falls_back_original_url(self, fake_db, fake_mediakit):
"""Bug2:外部音频转存失败不阻断,用原始 URL 建任务(失败后服务端重试/回退 MediaKit)。"""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
dashscope_url = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/tmp/abc.mp3"
job = _make_job(audio_url=dashscope_url)
gpu_done = MagicMock(id="gpu-task-4", status="done", result_url="gpu-lipsync/results/gpu-task-4.mp4")
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-4")
fake_gpu_svc.wait_for_result.return_value = gpu_done
with (
_patch_storage() as storage_p,
patch("app.services.lipsync_service.safe_download_bytes", side_effect=RuntimeError("network blocked")),
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
):
storage = storage_p()
svc._submit_audio_direct(job=job)
storage.upload_file.assert_not_called()
assert fake_gpu_svc.create_task.call_args.kwargs["audio_url"] == dashscope_url
class TestGpuServiceHelpers:
"""GpuLipsyncService.has_available_worker 测试."""
def test_no_workers(self, fake_db):
from app.services.gpu_lipsync_service import GpuLipsyncService
svc = GpuLipsyncService(db=fake_db)
fake_db.query.return_value.filter.return_value.first.return_value = None
assert svc.has_available_worker() is False
def test_fresh_worker_available(self, fake_db):
from app.services.gpu_lipsync_service import GpuLipsyncService
svc = GpuLipsyncService(db=fake_db)
svc.settings.gpu_worker_stale_seconds = 300
# 模拟SQL filter条件成立 → first() 返回非None
fake_db.query.return_value.filter.return_value.first.return_value = MagicMock()
assert svc.has_available_worker() is True
def test_stale_worker_unavailable(self, fake_db):
from app.services.gpu_lipsync_service import GpuLipsyncService
svc = GpuLipsyncService(db=fake_db)
# filter条件不成立(stale)→ first() 返回None
fake_db.query.return_value.filter.return_value.first.return_value = None
assert svc.has_available_worker() is False
+25 -14
View File
@@ -71,9 +71,7 @@ class TestRechargeOrderResponse:
cu = _make_cu()
body = PointsRechargeRequest(package_id="nonexistent")
with pytest.raises(HTTPException) as exc, patch(
"app.api.routes.points._get_service", return_value=svc
):
with pytest.raises(HTTPException) as exc, patch("app.api.routes.points._get_service", return_value=svc):
create_recharge_order(body=body, current_user=cu, db=db)
assert exc.value.status_code == 400
@@ -112,7 +110,10 @@ class TestCheckPointsUnknownScene:
cu = _make_cu()
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
with patch("app.api.routes.points._get_service", return_value=svc):
with (
patch("app.api.routes.points._credits_enabled", return_value=True),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = check_points(body=body, current_user=cu, db=db)
assert resp.required_points == 2 # ceil(1 * 1.15) = 2
assert resp.current_balance == 50
@@ -148,22 +149,30 @@ class TestSubscriptionPlans:
def _import_plans_fn():
"""Import from the real file to avoid sys.modules shadowing by integration fixtures."""
import importlib.util
_route_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "apps", "api", "app", "api", "routes", "subscription.py",
)
_spec = importlib.util.spec_from_file_location(
"_real_subscription_routes", os.path.abspath(_route_path)
"..",
"..",
"apps",
"api",
"app",
"api",
"routes",
"subscription.py",
)
_spec = importlib.util.spec_from_file_location("_real_subscription_routes", os.path.abspath(_route_path))
_mod = importlib.util.module_from_spec(_spec)
# inject settings before exec
import os as _os
_os.environ.setdefault("JWT_SECRET_KEY", "test-secret")
_spec.loader.exec_module(_mod)
return _mod.list_membership_plans
def test_plans_endpoint_returns_three_tiers(self):
import os # noqa: F401 (used by _import_plans_fn)
list_membership_plans = self._import_plans_fn()
resp = list_membership_plans(current_user=_make_cu())
plans = resp["plans"]
@@ -177,6 +186,7 @@ class TestSubscriptionPlans:
def test_longer_plans_cheaper_per_month(self):
import os # noqa: F401
list_membership_plans = self._import_plans_fn()
resp = list_membership_plans(current_user=_make_cu())
plans = resp["plans"]
@@ -211,9 +221,10 @@ class TestMultiplierConsistency:
db = MagicMock()
cu = _make_cu()
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
body = PointsCheckRequest(scene_key=scene, quantity=1)
with patch("app.api.routes.points._get_service", return_value=svc):
resp = check_points(body=body, current_user=cu, db=db)
expected = calculate_points_cost(scene, is_member=False, quantity=1)
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
with patch("app.api.routes.points._credits_enabled", return_value=True):
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
body = PointsCheckRequest(scene_key=scene, quantity=1)
with patch("app.api.routes.points._get_service", return_value=svc):
resp = check_points(body=body, current_user=cu, db=db)
expected = calculate_points_cost(scene, is_member=False, quantity=1)
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"