Compare commits

..

32 Commits

Author SHA1 Message Date
xiaoxia 9dbe6e4283 debug: verify staging post-deploy
Verify staging post-deploy / verify (push) Successful in 25s
2026-09-11 11:45:35 +08:00
xiaoxia 6f36abae9c fix(worker): 注册 ai_avatar_render Celery 任务到 Worker,修复 AI数字人渲染卡 0% (#1846)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
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 1m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m18s
CI/CD Pipeline / Validate - Style (push) Successful in 2m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m40s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m22s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m29s
CI/CD Pipeline / Validate - Security (push) Successful in 7m45s
CI/CD Pipeline / Unit Tests (push) Successful in 8m45s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 11:22:47 +08:00
xiaoxia e7ab963ae3 fix(web, P0): nginx entrypoint 在 CI bind mount 场景不应 rm default.conf(staging 502 阻塞部署) (#1856)
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m3s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
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 3m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m15s
CI/CD Pipeline / Validate - Security (push) Successful in 9m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 11:11:49 +08:00
xiaoxia 9c0d4b136f chore: trigger redeploy after nginx entrypoint fix
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
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 1m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m45s
CI/CD Pipeline / Validate - Style (push) Successful in 1m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m58s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m34s
CI/CD Pipeline / Validate - Security (push) Successful in 6m3s
CI/CD Pipeline / Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-11 10:49:25 +08:00
xiaoxia 387514c111 fix(web, P0): nginx entrypoint ln -sf 替换已有文件失败导致web容器无限重启,阻塞staging部署 (#1855)
CI/CD Pipeline / Check if frontend-only change (push) 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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m12s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m54s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Validate - Style (push) Successful in 2m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m9s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Validate - Security (push) Successful in 6m46s
CI/CD Pipeline / Unit Tests (push) Successful in 7m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 10:35:21 +08:00
xiaoxia 76cdb15c6b fix(lipsync, P0): Worker运行时ModuleNotFoundError导致任务卡在tts_processing (#1854)
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m27s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Style (push) Successful in 2m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m0s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m47s
CI/CD Pipeline / Validate - Security (push) Successful in 5m40s
CI/CD Pipeline / Unit Tests (push) Successful in 7m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 09:52:05 +08:00
xiaoxia 29ca51da9c fix(web): 烤入所有 nginx 配置,按 APP_ENV 运行时切换,彻底解决 staging/prod 配置错乱 (#1853)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 2m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m12s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m30s
CI/CD Pipeline / Unit Tests (push) Successful in 7m44s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 09:02:01 +08:00
xiaoxia a582d3b4dc fix(lipsync, P0): Worker imports路径修复,任务卡在tts_processing (#1852)
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m28s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Validate - Security (push) Successful in 4m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Unit Tests (push) Successful in 8m4s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 08:05:11 +08:00
xiaoxia 0ad33d429d fix(lipsync, P0): TTS异步任务改用@shared_task,解决Worker不注册任务导致卡tts_processing (#1851)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 59s
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 1m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m55s
CI/CD Pipeline / Validate - Style (push) Successful in 2m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 6m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Unit Tests (push) Successful in 8m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 01:07:09 +08:00
xiaoxia 582f73c2f2 fix(worker): 修复 worker 镜像漏 COPY apps/api/app 目录导致转码队列不消费(Staging E2E 阻塞) (#1850)
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m36s
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 2m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Unit Tests (push) Successful in 8m11s
CI/CD Pipeline / Validate - Security (push) Successful in 24m36s
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
2026-09-10 22:56:39 +08:00
xiaoxia 9ea014c39b fix(lipsync-tts): 修正 celery_app 导入路径,修复 staging api 启动阻断 (#1848)
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 56s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m39s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Unit Tests (push) Successful in 8m20s
CI/CD Pipeline / Validate - Security (push) Successful in 26m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-10 21:19:41 +08:00
xiaoxia 00a6516543 fix(ci): auto-fix 脚本补充 ruff check --fix(含 F401 等 unsafe fix)(#1847)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m23s
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) Failing after 4m18s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Failing after 12m58s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m31s
CI/CD Pipeline / Validate - Style (push) Successful in 2m13s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m25s
CI/CD Pipeline / Unit Tests (push) Successful in 8m23s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-10 20:48:14 +08:00
xiaoxia caa4ce118c fix(ci): auto-fix 脚本补充 ruff check --fix(含 F401 等 unsafe fix)
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m8s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m57s
AI Code Review / AI Code Review (pull_request) Successful in 6m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 24m37s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m42s
- fix_python() 在 black/isort 之后追加 ruff check --fix --unsafe-fixes
- 目标范围与 validate_style.sh 对齐:apps packages tests(alembic 被 ruff exclude,scripts 不在检查范围)
- ruff 不可用或修复后仍有残余问题时不阻断,剩余 lint 由 style check 继续拦截
- workflow step 名同步更新

解决 F401 等 ruff 可自动修复错误无法自动推修复、开发者反复打回的问题
2026-09-10 20:20:25 +08:00
xiaoxia b3f8c00522 fix(ai-avatar): 封面预览改为竖屏 9:16 比例 (#1845)
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 / 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 (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m58s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 8m21s
CI/CD Pipeline / Validate - Security (push) Successful in 24m35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-10 20:07:57 +08:00
xiaoxia f73837c7b4 feat: AI数字人对口型视频生成速度优化 - FFmpeg编码提速 + TTS合成异步化 (#1844)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
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 / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Validate - Style (push) Successful in 2m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m15s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m40s
CI/CD Pipeline / Unit Tests (push) Successful in 9m28s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / 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
2026-09-10 19:43:08 +08:00
xiaoxia f40375749b feat(ai-avatar): 渲染进度弹窗 + 实时百分比轮询 (#1839)
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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12s
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 1m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Style (push) Successful in 2m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m44s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
CI/CD Pipeline / Validate - Security (push) Successful in 6m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m11s
CI/CD Pipeline / Unit Tests (push) Successful in 9m30s
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: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-10 18:48:22 +08:00
xiaoxia af6772f1ba Merge pull request 'feat(ai-avatar): 渲染完成后自动保存成片记录到成片库' (#1841) from feat/auto-save-clip-on-render-complete into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m36s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Validate - Security (push) Successful in 6m46s
CI/CD Pipeline / Unit Tests (push) Successful in 8m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-09-10 18:33:14 +08:00
CI Bot 168fcce36e style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m46s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m59s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
2026-09-10 10:25:41 +00:00
xiaoxia ad580c1a4d test: 新增成片自动创建单元测试 + 修复 smart_cover mock
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m0s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m16s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 新增 test_execute_render_success_creates_clip_record:验证渲染完成后自动创建成片记录
- 新增 test_execute_render_clip_failure_does_not_affect_render:验证成片创建失败不影响渲染任务
- 修复 test_smart_cover_selects_best_frame_and_persists:补充 get_download_url mock
2026-09-10 18:23:26 +08:00
CI Bot bcaf1d967a style: auto-format with black + isort + prettier [skip ci-format-check] 2026-09-10 18:23:03 +08:00
xiaoxia dc12c62190 feat(ai-avatar): 渲染完成后自动保存成片记录到成片库
- 在 execute_render 方法的 job.status=completed 之后,自动创建 GeneratedVideo 记录
- 复用现有 GeneratedVideo 模型和 SQLAlchemyGeneratedVideoRepository
- 成片记录包含:视频URL、封面URL、时长、用户ID、项目ID
- 生成参数中记录来源(render_job_id)以便追溯
- 创建失败时仅打 warning 日志,不影响渲染任务状态
2026-09-10 18:23:03 +08:00
xiaoxia 4a2140cd8b Merge pull request 'fix: 清理22个F841 unused-variable,解除CI Validate-Style阻塞' (#1842) from fix/f841-unused-variables into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
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 1m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Failing after 14m35s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m55s
CI/CD Pipeline / Validate - Style (push) Successful in 2m25s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m55s
CI/CD Pipeline / Validate - Security (push) Successful in 6m46s
CI/CD Pipeline / Unit Tests (push) Successful in 7m58s
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
2026-09-10 18:01:33 +08:00
xiaoxia a526a28865 fix(ai-avatar): 修复智能封面502 - cover_service视频URL重签+轮询扩容+连接池+路由异常兜底 (#1843)
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 (push) Successful in 12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Validate - Style (push) Failing after 3m1s
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 1m14s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-10 17:55:41 +08:00
xiaoxia ff833ce7e0 fix: 清理22个F841 unused-variable,解除CI Validate-Style阻塞
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m8s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m33s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 41s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 37s
全部为 except Exception as e: 中变量e未使用的模式,
ruff自动修复将 e: 改为 :。不涉及任何业务逻辑变更。
2026-09-10 17:39:08 +08:00
CI Bot 129e5a957d style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 44s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
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 1m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
CI/CD Pipeline / Validate - Style (push) Failing after 2m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m38s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m21s
CI/CD Pipeline / Unit Tests (push) Failing after 7m50s
CI/CD Pipeline / Validate - Security (push) Successful in 7m53s
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
2026-09-10 07:54:48 +00:00
灵应 9a323a2a11 feat(ai-avatar): 对口型预览标题支持拖拽定位
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 24s
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 1m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Failing after 12m56s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m23s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
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
对齐智能剪辑 FrontendPreviewPlayer 的拖拽逻辑:
- pointer 事件捕获 + 拖拽时直接改 DOM(不触发 React 重渲染)
- 拖拽结束时调用 onTitlePositionChange 保存 pos_x/pos_y
- 标题样式加 cursor: grab/grabbing + pointerEvents: auto
- 主页面传 updateTitleConfig 回调
2026-09-10 15:38:16 +08:00
灵应 bf6d02f26f fix(ai-avatar): 标题分行/封面签名URL/渲染b_roll结构对齐后端
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m5s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Style (push) Failing after 2m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
1. 标题 / 分行: 对齐智能剪辑 FrontendPreviewPlayer 的 split(/[//]/) 逻辑,/ 和 / 都会分行
2. 封面签名URL: 私有桶 OSS upload 后必须 get_download_url 签名,否则前端无法加载
3. 渲染 b_roll_segments 结构: 前端 {asset: AssetItem} 转后端 {asset_url: string},对齐 BRollSegment schema
4. #1821 对口型加速工单已关闭(收益有限,MediaKit GPU 排队是主要瓶颈)
2026-09-10 15:33:33 +08:00
CI Bot e730043b2f style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Style (push) Failing after 2m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Validate - Security (push) Successful in 6m4s
CI/CD Pipeline / Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 20m53s
2026-09-10 05:47:42 +00:00
CI Test 6b925a6f4d fix(#1830): 改进异常处理 - 缩小异常类型并添加日志记录
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
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 1m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m25s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-10 13:44:51 +08:00
灵应 211166f203 fix(ai-avatar): 预览缩放/标题叠加/渲染422/智能封面超时
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m43s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
1. 对口型预览缩小: .aa-lipsync-preview 加 max-width:240px + 居中,9:16 等比不变,约原大小 1/2
2. 标题实时叠加预览: 视频容器 relative + 标题 absolute 定位(top/bottom/center 按配置),字号按 0.55 倍等比缩
3. 渲染 422 修复: submitRender 删掉 resolution 字段(后端 CreateAiAvatarRenderRequest schema 无此字段,Pydantic 校验失败)
4. 智能封面超时: generateSmartCover 接口超时从默认 10s 加到 60s(MediaKit 抽帧+评分+OSS转存耗时 >10s)

不改其他功能,无冗余代码。
2026-09-10 13:42:16 +08:00
灵应 7dc2954d8f fix(ai-avatar): 恢复原始UI样式,页面拆分为两步骤仅做UI调整
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m5s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m28s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Validate - Style (push) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Validate - Security (push) Successful in 5m50s
CI/CD Pipeline / Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- 恢复 AiAvatar.css 为原始版本(1178行逐字节一致),仅在末尾追加70行步骤导航/分步面板宽度样式
- 保持原始横向 flex 布局与全部原有 class/样式,撤销上一版纵向堆叠改动
- 步骤1:出镜视频/配音库/文案(3个横向面板)+ 下一步按钮
- 步骤2:对口型预览(含插入画面)/标题配置/封面&生成(3个横向面板)+ 上一步按钮
- PanelScriptAndLipsync 拆为 PanelScript + PanelLipsyncPreview,业务逻辑/props/弹窗全部原样保留
- 不改任何功能,不动其他页面
2026-09-10 13:16:19 +08:00
xiaoxia 6c77b066de fix(lipsync): 私有桶 OSS 媒体 URL 重签修复对口型 403 (#1839) (#1837)
CI/CD Pipeline / Check if frontend-only change (push) 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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
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 1m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (push) Successful in 1m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m43s
CI/CD Pipeline / Validate - Security (push) Successful in 5m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 7m49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-10 13:05:28 +08:00
39 changed files with 3115 additions and 746 deletions
+1 -10
View File
@@ -196,7 +196,7 @@ jobs:
- name: Run style checks
shell: bash
run: bash scripts/ci/validate_style.sh
- name: Auto-fix formatting (black + isort)
- name: Auto-fix formatting (black + isort + ruff)
if: failure()
shell: sh
env:
@@ -827,9 +827,6 @@ jobs:
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
# Worker 与 API/Web 统一走持久 builderci-builder-persist),共享宿主机层缓存
NO_CACHE_FLAG=""
@@ -1026,9 +1023,6 @@ jobs:
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
NO_CACHE_FLAG=""
for i in 1 2 3; do
@@ -1567,9 +1561,6 @@ jobs:
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
fi
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
NO_CACHE_FLAG=""
+60
View File
@@ -0,0 +1,60 @@
name: "Debug: Web container v2 (mount conflict)"
on:
push:
branches: [debug/web-crash-v2]
workflow_dispatch:
jobs:
web-diag:
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Setup SSH and diagnose
shell: bash
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -x
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
H=47.98.113.167; P=22222
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
ssh -p $P -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@$H 'bash -s' <<'REMOTE'
set -x
echo "=== Current staging containers ==="
docker ps -a --filter name=xiaoxia-*-staging --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
echo ""
echo "=== Web container logs (current/current-rolledback) ==="
docker logs xiaoxia-web-staging 2>&1 | tail -40
echo ""
echo "=== Web inspect: env & mounts ==="
docker inspect xiaoxia-web-staging --format 'Entrypoint: {{.Config.Entrypoint}} Cmd: {{.Config.Cmd}}'
docker inspect xiaoxia-web-staging --format '{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' | grep -E "APP_ENV|VERSION"
echo "Mounts:"
docker inspect xiaoxia-web-staging --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} (rw={{.RW}}){{"\n"}}{{end}}'
echo ""
echo "=== Reproduce: rm on read-only bind mount ==="
docker run --rm --name nginx-ro-test \
-v /var/lib/xiaoxia-saas-staging/nginx-staging.conf:/etc/nginx/conf.d/default.conf:ro \
git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/xiaoxia-saas-web:387514c \
sh -c '
set -x
echo "Before:"
ls -la /etc/nginx/conf.d/
echo "Try rm (as entrypoint does):"
rm -f /etc/nginx/conf.d/default.conf
echo "rm exitcode=$?"
echo "After rm:"
ls -la /etc/nginx/conf.d/
echo "Test ln:"
ln -s /etc/nginx/nginx-staging.conf /etc/nginx/conf.d/default.conf
echo "ln exitcode=$?"
ls -la /etc/nginx/conf.d/
echo "nginx -t:"
nginx -t 2>&1
' 2>&1
echo ""
echo "=== Also test with NEW fixed image (9c0d4b1 if present) ==="
docker images | grep xiaoxia-saas-web | head -5
REMOTE
+29
View File
@@ -0,0 +1,29 @@
name: "Verify staging post-deploy"
on:
push:
branches: [debug/verify-staging]
workflow_dispatch:
jobs:
verify:
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Setup SSH
shell: bash
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
H=47.98.113.167; P=22222
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
- name: Run verify
shell: bash
run: |
set -x
echo 'IyEvYmluL2Jhc2gKc2V0IC14CmVjaG8gIj09PSAxLiBDdXJyZW50IHJ1bm5pbmcgY29udGFpbmVycyA9PT0iCmRvY2tlciBwcyAtLWZpbHRlciBuYW1lPXhpYW94aWEtKi1zdGFnaW5nIC0tZm9ybWF0ICJ0YWJsZSB7ey5OYW1lc319XHR7ey5TdGF0dXN9fVx0e3suSW1hZ2V9fSIKZWNobyAiIgplY2hvICI9PT0gMi4gV2ViIGxvZ3MgKGxhc3QgMTUgbGluZXMpID09PSIKZG9ja2VyIGxvZ3MgLS10YWlsIDE1IHhpYW94aWEtd2ViLXN0YWdpbmcgMj4mMQplY2hvICIiCmVjaG8gIj09PSAzLiBXZWIgbG9jYWxob3N0IGhlYWx0aCBjaGVjayA9PT0iCmN1cmwgLXNJIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjMwMDEvIHwgaGVhZCAtNQplY2hvICIiCmVjaG8gIj09PSA0LiBBUEkgbG9jYWxob3N0IGhlYWx0aCBjaGVjayA9PT0iCmN1cmwgLXNmIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoCmVjaG8gIiIKZWNobyAiPT09IDUuIEFQSSB2ZXJzaW9uID09PSIKY3VybCAtcyAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aCB8IHB5dGhvbjMgLWMgImltcG9ydCBzeXMsanNvbjsgZD1qc29uLmxvYWQoc3lzLnN0ZGluKTsgcHJpbnQoJ3ZlcnNpb246JywgZC5nZXQoJ3ZlcnNpb24nLCc/JykpIiAyPi9kZXYvbnVsbAplY2hvICIiCmVjaG8gIj09PSA2LiBXb3JrZXIgY29udGFpbmVyIHZlcnNpb24gPT09Igpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdvcmtlci1zdGFnaW5nIC0tZm9ybWF0ICd7e3JhbmdlIC5Db25maWcuRW52fX17ey59fXt7IlxuIn19e3tlbmR9fScgfCBncmVwIEFQUF9WRVJTSU9OCmVjaG8gIiIKZWNobyAiPT09IDcuIFdvcmtlciBsb2dzIChzdGFydHVwICsgdGFzayByZWdpc3RyYXRpb24pID09PSIKZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtd29ya2VyLXN0YWdpbmcgMj4mMSB8IGdyZXAgLWlFICJyZWdpc3RlcmVkfGNlbGVyeUB8bGlwc3luY3xyZWFkeXxNb2R1bGVOb3RGb3VuZHxFcnJvcnx0YXNrIiB8IGhlYWQgLTMwCmVjaG8gIiIKZWNobyAiPT09IDguIExpc3QgYWxsIHJlZ2lzdGVyZWQgY2VsZXJ5IHRhc2tzIChXb3JrZXLlhoVpbnNwZWN0KSA9PT0iCmRvY2tlciBleGVjIHhpYW94aWEtd29ya2VyLXN0YWdpbmcgc2ggLWMgImNlbGVyeSAtQSB3b3JrZXJfYXBwLmNlbGVyeV9hcHAgaW5zcGVjdCByZWdpc3RlcmVkIDI+JjEgfCBoZWFkIC00MCIgfHwgZWNobyAiY2VsZXJ5IGluc3BlY3QgZmFpbGVkIgplY2hvICIiCmVjaG8gIj09PSA5LiBDaGVjayBpbXBvcnRzIGluc2lkZSBXb3JrZXIgKGxpcHN5bmNfdHRzIG1vZHVsZSkgPT09Igpkb2NrZXIgZXhlYyB4aWFveGlhLXdvcmtlci1zdGFnaW5nIHB5dGhvbiAtYyAiCmltcG9ydCBpbXBvcnRsaWIKbSA9IGltcG9ydGxpYi5pbXBvcnRfbW9kdWxlKCdhcHAudGFza3MubGlwc3luY190dHMnKQpwcmludCgnbGlwc3luY190dHMgbW9kdWxlIGltcG9ydGVkIE9LJykKcHJpbnQoJ3N5bnRoZXNpemVfYW5kX3N1Ym1pdDonLCBoYXNhdHRyKG0sICdzeW50aGVzaXplX2FuZF9zdWJtaXQnKSkKcHJpbnQoJ1Nlc3Npb25Mb2NhbDonLCBoYXNhdHRyKG0sICdTZXNzaW9uTG9jYWwnKSkKIiAyPiYxIHwgaGVhZCAtMTAKZWNobyAiIgplY2hvICI9PT0gMTAuIFB1YmxpYyBBUEkgaGVhbHRoIGNoZWNrIChzdGFnaW5nLWFwaSkgPT09IgpjdXJsIC1zSSAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy1hcGkueGlhb3hpYWppYW5qaS5jb20vaGVhbHRoIHwgaGVhZCAtMwplY2hvICIiCmVjaG8gIj09PSAxMS4gUHVibGljIHdlYiBoZWFsdGggY2hlY2sgPT09IgpjdXJsIC1zSSAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy54aWFveGlhamlhbmppLmNvbS8gfCBoZWFkIC0zCg==' | base64 -d > /tmp/verify.sh
chmod +x /tmp/verify.sh
ssh -p 22222 -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@47.98.113.167 'bash -s' < /tmp/verify.sh
View File
+2 -2
View File
@@ -177,8 +177,8 @@ def _cleanup_expired_uploads() -> int:
meta_file.unlink()
cleaned += 1
logger.info(f"Cleaned up expired upload: {upload_id}")
except Exception as e:
logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}")
except Exception:
logger.exception("Failed to cleanup upload metadata: %s", meta_file)
return cleaned
@@ -108,7 +108,7 @@ def create_variant_plans(
if _latest:
source_plan_id = _latest.id
except Exception:
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
logger.exception("[variant-plans] 源 plan 解析失败")
if not source_plan_id:
raise HTTPException(
@@ -122,7 +122,7 @@ def create_variant_plans(
voice_durations = _query_voice_durations(db, voices)
except Exception:
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
logger.exception("[variant-plans] 配音时长查询失败(按占位段长选片)")
voice_durations = [0.0] * request.count
from app.services.edit_plan_service import EditPlanService
@@ -143,7 +143,7 @@ def create_variant_plans(
except HTTPException:
raise
except Exception as e:
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
logger.exception("[variant-plans] 选片异常")
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
# 组装 clips 响应
+7 -20
View File
@@ -14,7 +14,6 @@ import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_cosyvoice_service,
get_db_session,
get_voice_clone_profile_repository,
)
@@ -24,8 +23,6 @@ from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -34,13 +31,11 @@ router = APIRouter()
def _get_service(
db: Session = Depends(get_db_session),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> LipsyncService:
# voice_clone_repo 用于克隆音色 profile 解析cosyvoice_service 用于 TTS 直生
# TTS 合成、音色解析、错误码归一化都在 LipsyncService 内部完成)
# voice_clone_repo 用于克隆音色 profile 解析
# TTS 合成已移至 Celery 异步任务,无需同步注入 cosyvoice_service
return LipsyncService(
db,
cosyvoice_service=cosyvoice_service,
voice_clone_repo=voice_clone_repo,
)
@@ -57,8 +52,8 @@ def create_lipsync_job(
"""提交对口型任务.
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?}
后端内部解析音色、调 TTS 合成音频、转存 OSS,再提交 MediaKit
也支持直接传 {video_url, audio_url}。
后端创建任务记录(状态 tts_processing),dispatch Celery 异步任务执行 TTS 合成 + MediaKit 提交
也支持直接传 {video_url, audio_url}(同步提交 MediaKit
"""
try:
job = svc.create_job(
@@ -75,21 +70,13 @@ def create_lipsync_job(
except ValueError as exc:
# 参数无效(如 voice_id 格式不对、文本过长等)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except CosyVoiceError as exc:
# TTS 合成基础设施失败(API/网络/认证)
raise HTTPException(
status_code=502,
detail={"code": "TTSSynthesisFailed", "message": str(exc)},
) from exc
except MediaKitError as exc:
# TTS 合成失败 / 音色无权访问 → 400/403MediaKit 提交失败 → 502
# 音色无权访问 → 403;参数无效 → 400MediaKit 提交失败 → 502
status_code = 502
if exc.code in ("VoiceForbidden",):
status_code = 403
elif exc.code in ("InvalidInput", "TTSInvalidParam", "VoiceNotReady"):
status_code = 400
elif exc.code == "TTSSynthesisFailed":
status_code = 502
raise HTTPException(
status_code=status_code,
detail={
@@ -187,13 +174,13 @@ def cancel_lipsync_job(
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
"""取消对口型任务(仅 pending/tts_processing/submitted 状态可取消)."""
job = svc.cancel_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status != "cancelled":
raise HTTPException(
status_code=400,
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted 可取消",
)
return job
@@ -65,8 +65,8 @@ def _build_asset_analyses(
if url:
video_urls.append(url)
valid_asset_ids.append(aid)
except Exception as e:
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
except Exception:
logger.exception("获取素材URL失败: asset_id=%s", aid)
if not video_urls:
logger.info("无可用视频素材,跳过视频理解分析")
@@ -108,7 +108,7 @@ def _build_asset_analyses(
return analyses
except Exception as e:
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
logger.exception("MediaKit 视频理解异常,将降级到无分析模式: %s", e)
return {}
@@ -177,7 +177,7 @@ def editor_ai_recommend(
try:
db.rollback()
except Exception:
pass
logger.exception("db rollback failed in ai_recommend")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
@@ -132,8 +132,8 @@ def _build_asset_url_map(
result: dict[str, str | None] = {}
try:
storage = get_storage_service()
except Exception:
logger.warning("获取存储服务失败,跳过asset_url生成")
except Exception as e:
logger.exception("获取存储服务失败,跳过asset_url生成: %s", e)
return {aid: None for aid in asset_ids}
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
@@ -141,7 +141,7 @@ def _build_asset_url_map(
assets = asset_repo.find_by_ids(unique_ids)
asset_map = {a.id: a for a in assets}
except Exception:
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
logger.exception("批量查询素材失败: asset_ids=%s", asset_ids)
return {aid: None for aid in asset_ids if aid}
for aid in unique_ids:
@@ -156,7 +156,7 @@ def _build_asset_url_map(
continue
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
except Exception:
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
logger.exception("生成素材签名URL失败: asset_id=%s", aid)
result[aid] = None
return result
@@ -486,8 +486,8 @@ def _get_mediakit_recommendations(
if url:
video_urls.append(url)
valid_asset_ids.append(asset_id)
except Exception as e:
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
except Exception:
logger.exception("获取素材URL失败: asset_id=%s", asset_id)
if not video_urls:
return {}
@@ -563,7 +563,7 @@ def _get_mediakit_recommendations(
return recommendations
except Exception as e:
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
logger.exception("MediaKit 智能选片异常,降级为随机选择: %s", e)
return {}
@@ -860,7 +860,7 @@ def create_clips_from_assets_editor(
duplicate_warning = None
if dup_rate > 50:
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
logger.warning(
logger.exception(
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
plan_id,
dup_rate,
@@ -960,8 +960,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
# 尝试获取存储服务(用于生成视频 URL)
try:
storage = get_storage_service()
except Exception:
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
except Exception as e:
logger.exception("后台任务: 获取存储服务失败,跳过 SceneChange 更新: %s", e)
return
# 获取 MediaKit 客户端
@@ -987,8 +987,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
if storage_key and mime.startswith("video/"):
try:
video_url = storage.get_download_url(storage_key)
except Exception as e:
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
except Exception:
logger.exception("后台任务: 获取素材URL失败: asset_id=%s", asset_id)
# 构建该素材的占用区间列表(排除已更新片段)
def _get_other_segments(asset_id_inner, clip_id_inner):
@@ -1039,12 +1039,11 @@ def _update_mediakit_recommendations_async( # pragma: no cover
asset_id,
len(scene_changes),
)
except Exception as cache_err:
except Exception:
# 缓存写入失败不影响本次片段更新
logger.warning(
"后台任务: 场景点缓存写入失败: asset_id=%s error=%s",
logger.exception(
"后台任务: 场景点缓存写入失败: asset_id=%s",
asset_id,
cache_err,
)
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
@@ -1124,11 +1123,10 @@ def _update_mediakit_recommendations_async( # pragma: no cover
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
except Exception:
logger.exception(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s",
clip.id,
me,
)
db.rollback()
continue
@@ -1144,8 +1142,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
asset_id,
recommended_start,
)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
except Exception:
logger.exception("后台任务: 单个片段更新失败: clip_id=%s", clip.id)
try:
db.rollback()
except Exception:
@@ -1154,9 +1152,9 @@ def _update_mediakit_recommendations_async( # pragma: no cover
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
except Exception as e:
except Exception:
# 后台任务失败不影响已创建的片段,静默处理
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
logger.exception("后台任务异常: plan_id=%s", plan_id)
if db:
try:
db.rollback()
+6 -6
View File
@@ -163,12 +163,12 @@ def create_voice_clone(
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
logger.info(f"Celery task dispatched for voice clone {profile.id}")
except Exception as e:
logger.error(f"Failed to dispatch Celery task: {e}")
logger.exception("Failed to dispatch Celery task")
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
try:
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
except Exception:
logger.exception("Failed to mark profile as failed after dispatch error")
return _to_response(profile)
@@ -277,12 +277,12 @@ def retry_voice_clone(
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
except Exception as e:
logger.error(f"Failed to dispatch Celery task: {e}")
logger.exception("Failed to dispatch Celery task")
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
try:
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
except Exception:
logger.exception("Failed to mark profile as failed after dispatch error")
return _to_response(profile)
+4 -3
View File
@@ -105,8 +105,8 @@ def _resolve_preset_preview_url(
_preset_preview_cache[voice_id] = (audio_url, time.time())
logger.info("Preset voice preview generated: %s", voice_id)
return audio_url
except Exception as e:
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
except Exception:
logger.exception("Failed to generate preset voice preview: voice_id=%s", voice_id)
return fallback_url
@@ -127,6 +127,7 @@ def _resolve_all_preset_preview_urls(
try:
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
except Exception:
logger.exception("Failed to resolve preset preview URL: voice_id=%s", p.voice_id)
result_map[p.voice_id] = p.preview_url
return result_map
@@ -732,7 +733,7 @@ def _find_or_create_voice_library_for_extract(*, user_id, project_repository, as
try:
session.rollback()
except Exception:
pass
logger.exception("session rollback failed in _find_or_create_voice_library")
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
View File
@@ -28,6 +28,9 @@ FRAME_DOWNLOAD_TIMEOUT = 20
# 最佳帧下载超时(用于 persist)
BEST_FRAME_DOWNLOAD_TIMEOUT = 30
# 自家 OSS 私有桶 URL 重签有效期(供 MediaKit GPU worker 拉取)
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
def _sign_video_url_for_mediakit(video_url: str) -> str:
"""如果 video_url 是自家 OSS 私有桶 URL,重新签名为长有效期预签名 URL。
@@ -47,7 +50,7 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
url_host = urlparse(video_url).netloc.lower()
if own_host and url_host == own_host:
# 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取
signed = storage.get_download_url(video_url, expires_seconds=7 * 24 * 3600)
signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
if signed:
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
return signed
@@ -192,7 +195,11 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-
content_type="image/jpeg",
)
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
return public_url or frame_url
# 私有桶:返回预签名 URL(前端才能加载)
if public_url:
signed = storage.get_download_url(cover_key, expires_seconds=86400)
return signed
return frame_url
except Exception:
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
return frame_url
@@ -322,6 +322,38 @@ class AiAvatarRenderService:
self.db.commit()
logger.info("渲染任务完成: %s", job_id)
# 7. 自动保存成片记录到成片库
if job.output_video_url:
try:
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job_id[:8]}"
clip = GeneratedVideo.create(
project_id=job.project_id,
generation_task_id=job.lipsync_job_id,
name=clip_name,
file_url=job.output_video_url,
user_id=job.user_id,
duration=job.output_duration or 0.0,
thumbnail_url=job.output_cover_url or None,
generation_params={
"source": "ai_avatar_render",
"render_job_id": job.id,
},
)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
except Exception as clip_err:
logger.warning(
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s, error=%s",
job_id,
clip_err,
)
except AiAvatarRenderError as exc:
job.status = "failed"
job.error_message = str(exc)
@@ -376,7 +408,7 @@ class AiAvatarRenderService:
else:
filter_arg = ""
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset fast -crf 23 -y {output_path}"
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
"""上传文件到 OSS,返回 URL.
+95 -35
View File
@@ -15,6 +15,7 @@ import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlparse
from app.services.mediakit_client import (
STATUS_COMPLETED,
@@ -24,6 +25,9 @@ from app.services.mediakit_client import (
MediaKitError,
get_mediakit_client,
)
# Celery 异步任务:TTS 合成 + MediaKit 提交(#lipsync-speed-optimization
from app.tasks.lipsync_tts import tts_synthesize_and_submit
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
@@ -33,6 +37,10 @@ from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download
logger = logging.getLogger(__name__)
# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。
# MediaKit 排队 + 拉取可能延迟,私有桶裸 URL 或 1 小时短预签名都会 403,故统一重签长有效期。
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
class LipsyncService:
"""对口型任务 Service."""
@@ -149,35 +157,31 @@ class LipsyncService:
enable_video_loop: bool = False,
project_id: str = "",
) -> LipsyncJobModel:
"""创建对口型任务并提交到 MediaKit.
"""创建对口型任务.
两种输入模式:
- TTS 直生:voice_id + script_textaudio_url 留空),后端先合成音频
- TTS 直生:voice_id + script_textaudio_url 留空)
→ 先创建 DB 记录(状态 tts_processing),再 dispatch Celery 异步任务
执行 TTS 合成 + MediaKit 提交。API 响应 <1s。
- 直接音频:提供 audio_url
→ 同步提交 MediaKit,状态直接设为 submitted。
Raises:
MediaKitError: TTS 合成或 MediaKit 提交失败
MediaKitError: 参数校验失败或 MediaKit 提交失败(仅直接音频模式)
"""
# 0. TTS 直生模式:先合成音频(在创建 DB 记录之前完成,失败直接抛出)
# 0. 输入校验
if not audio_url:
if not (voice_id and script_text):
raise MediaKitError(
"必须提供 audio_url 或 voice_id+script_text",
code="InvalidInput",
)
# 预合成:用临时 job_id 命名 OSS 对象
pre_job_id = str(uuid.uuid4())
audio_url = self._synthesize_and_persist_audio(
user_id=user_id,
job_id=pre_job_id,
voice_id=voice_id,
script_text=script_text,
speed=speed,
emotion=emotion,
)
# TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败
self._resolve_voice_id(voice_id, user_id)
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
is_tts_mode = not bool(audio_url)
job = LipsyncJobModel(
id=job_id,
user_id=user_id,
@@ -189,28 +193,59 @@ class LipsyncService:
script_text=script_text or "",
speed=speed,
emotion=normalize_emotion(emotion),
status="pending",
status="tts_processing" if is_tts_mode else "pending",
)
self.db.add(job)
self.db.flush()
# 3. 提交到 MediaKit
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
client_token=job_id, # 幂等控制
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
raise
if is_tts_mode:
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
try:
tts_synthesize_and_submit.apply_async(
args=(
job_id,
user_id,
voice_id,
script_text,
speed,
normalize_emotion(emotion),
)
)
except Exception as exc:
# 投递失败时立即把 job 标成 failed 并写入 error_message
# 前端轮询时能直接看到失败原因,不会无限卡在 tts_processing。
logger.exception(
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
job_id,
exc,
)
job.status = "failed"
job.error_message = f"Celery 任务投递失败: {exc}"
job.error_code = "AsyncDispatchFailed"
job.updated_at = datetime.now(timezone.utc)
else:
# 2b. 直接音频模式:同步签名并提交 MediaKit
video_url = self._sign_media_url(video_url)
if audio_url:
audio_url = self._sign_media_url(audio_url)
job.audio_url = audio_url
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
client_token=job_id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
raise
self.db.commit()
self.db.refresh(job)
@@ -316,20 +351,45 @@ class LipsyncService:
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
logger.info("对口型输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
return permanent_url or temp_url
return self._sign_media_url(permanent_url) or temp_url
except Exception as exc:
logger.warning("对口型输出视频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
return temp_url
def _sign_media_url(self, url: str) -> str:
"""对自家 OSS 私有桶 URL 重签长有效期预签名,供 MediaKit 拉取 / 前端播放。
- 裸 public_urlupload_file 返回,不带签名)→ 私有桶匿名访问 403,重签。
- 已带签名但即将过期的 URL(如前端 1h 预签名)→ 抽 storage_key 后重签。
- 外部 URLCosyVoice/MediaKit 临时链接,非本桶 host)→ 原样透传。
- 任何异常都降级原样返回,不阻断主流程。
"""
if not url:
return url
try:
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url # 无法判定归属,保守透传
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url # 非自家 OSS(外部临时链接),不处理
signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
return signed or url
except Exception as exc: # noqa: BLE001 - 签名失败不阻断,降级原 URL
logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
return url
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""取消任务(仅 pending/submitted 状态可取消)."""
"""取消任务(仅 pending/tts_processing/submitted 状态可取消)."""
job = self.get_job(job_id, user_id)
if job is None:
return None
if job.status in ("pending", "submitted"):
if job.status in ("pending", "tts_processing", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
+222
View File
@@ -0,0 +1,222 @@
"""AI 数字人对口型 TTS 异步任务 — 将 TTS 合成从 HTTP 请求移至 Celery 后台执行.
优化目标:将 create_job 的 API 响应时间从 6~35s 降到 <1s。
任务流程:
1. 创建新 DB session,加载 job 记录
2. 调用 CosyVoice 合成音频
3. 下载音频并转存到自家 OSS
4. 更新 job 的 audio_url
5. 签名 URL 并提交到 MediaKit
6. 更新 job 状态为 submitted
7. 异常时标记 job 为 failed
注意:使用 @shared_task 而非绑定到某个 celery_app 实例,
确保任务能被 Worker 侧 celery_app 正确注册,同时 API 侧 send_task/apply_async 仍可正常调用。
"""
import io
import logging
from datetime import datetime, timezone
from urllib.parse import urlparse
from celery import shared_task
logger = logging.getLogger(__name__)
# MediaKit 预签名 URL 有效期(7天,秒),与 LipsyncService._sign_media_url 保持一致
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
def _sign_media_url(url: str) -> str:
"""对自家 OSS 私有桶 URL 重签长有效期预签名.
- 自家 OSS URL → 重签 7 天有效期
- 外部临时 URL → 原样透传
- 任何异常降级原样返回,不阻断主流程
"""
if not url:
return url
try:
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
signed = storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
return signed or url
except Exception as exc: # noqa: BLE001
logger.warning("[lipsync_tts] URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
return url
@shared_task(
bind=True,
name="lipsync_tts.synthesize_and_submit",
max_retries=2,
default_retry_delay=30,
)
def tts_synthesize_and_submit(
self,
job_id: str,
user_id: str,
voice_id: str,
script_text: str,
speed: float,
emotion: str,
):
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
在 Celery worker 中运行,不阻塞 HTTP 请求。
"""
from app.services.mediakit_client import MediaKitError, get_mediakit_client
from sqlalchemy.orm import Session as DBSession
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.shared.url_security import safe_download_bytes
# SessionLocal 获取:
# - API 容器:app.db.SessionLocal(环境变量完整,导入即建引擎)
# - Worker 容器:worker_app.db.SessionLocalWorker 自己的 settings 初始化引擎)
# API 侧没有 worker_app 模块 → ImportError 直接回退;
# Worker 侧 app.db 会因缺少 API 专有环境变量抛 pydantic ValidationError
# 此时也要回退到 worker_app.db。
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
db: DBSession = SessionLocal()
try:
job = (
db.query(LipsyncJobModel)
.filter(
LipsyncJobModel.id == job_id,
LipsyncJobModel.user_id == user_id,
)
.first()
)
if job is None:
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
return
# 已取消的任务不再处理
if job.status == "cancelled":
logger.info("[lipsync_tts] Job already cancelled, skipping: job_id=%s", job_id)
return
# 1. TTS 合成
try:
cosyvoice = CosyVoiceService()
result = cosyvoice.submit_synthesize_task(
text=script_text,
voice_id=voice_id,
speed=speed,
emotion=emotion,
)
except CosyVoiceError as exc:
logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc)
job.status = "failed"
job.error_message = f"TTS 合成失败: {exc}"
job.error_code = "TTSSynthesisFailed"
job.updated_at = datetime.now(timezone.utc)
db.commit()
return
except ValueError as exc:
logger.error("[lipsync_tts] TTS 参数错误: job_id=%s err=%s", job_id, exc)
job.status = "failed"
job.error_message = f"TTS 参数错误: {exc}"
job.error_code = "TTSInvalidParam"
job.updated_at = datetime.now(timezone.utc)
db.commit()
return
temp_url = result.get("audio_url", "")
if not temp_url:
logger.error("[lipsync_tts] TTS 未返回音频 URL: job_id=%s", job_id)
job.status = "failed"
job.error_message = "TTS 未返回音频 URL"
job.error_code = "TTSNoAudio"
job.updated_at = datetime.now(timezone.utc)
db.commit()
return
# 2. 下载并转存到自家 OSS
try:
audio_data = safe_download_bytes(
temp_url,
purpose="lipsync_tts_audio",
allowed_mime_types=(
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/mp4",
"audio/x-m4a",
),
timeout=60.0,
)
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
job.audio_url = permanent_url
except Exception as exc:
logger.warning(
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
job_id,
exc,
)
job.audio_url = temp_url
db.commit()
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
client = get_mediakit_client()
try:
mk_result = client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job_id,
)
job.mediakit_task_id = mk_result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
logger.info(
"[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s",
job_id,
mk_result["task_id"],
)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("[lipsync_tts] 提交 MediaKit 失败: job_id=%s err=%s", job_id, exc)
db.commit()
except Exception:
logger.exception("[lipsync_tts] 未预期的异常: job_id=%s", job_id)
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id).first()
if job and job.status not in ("cancelled", "failed", "completed"):
job.status = "failed"
job.error_message = "TTS 异步任务执行异常"
job.error_code = "AsyncTaskError"
job.updated_at = datetime.now(timezone.utc)
db.commit()
except Exception:
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
finally:
db.close()
File diff suppressed because it is too large Load Diff
+236 -112
View File
@@ -1,10 +1,11 @@
/**
* AI数字人 — 主页面(两步骤版)
* AI数字人 — 主页面(v3 两步骤版)
* 步骤1:出镜视频 / 配音库 / 文案
* 步骤2插入画面 / 对口型预览 / 标题配置 / 封面&生成
* 步骤2对口型预览(含插入画面)/ 标题配置 / 封面&生成
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
import { useNavigate } from "react-router-dom"
import "./AiAvatar.css"
import { useAiAvatar } from "./hooks/useAiAvatar"
import { PanelVideoSelector } from "./components/PanelVideoSelector"
@@ -21,6 +22,7 @@ import {
createLipsyncJob,
getLipsyncJob,
submitRender,
getRenderJob,
generateSmartCover,
} from "./api/aiAvatar"
import {
@@ -34,7 +36,8 @@ type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
const AiAvatarPage: React.FC = () => {
const state = useAiAvatar()
const [currentStep, setCurrentStep] = useState(1)
const navigate = useNavigate()
const [currentStep, setCurrentStep] = useState<1 | 2>(1)
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
video: false,
voice: false,
@@ -50,35 +53,45 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ─ 智能封面加载态 ── */
/* ─ 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
"generating",
)
const [renderProgress, setRenderProgress] = useState(0)
const [renderErrorMessage, setRenderErrorMessage] = useState("")
/* ── 对口型轮询 ── */
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度轮询 ── */
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const togglePanel = useCallback((key: PanelKey) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
/* ── 步骤切换 ── */
const canGoToStep2 = useCallback(() => {
return state.selectedVideo && state.selectedVoice && state.scriptText.trim()
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handleNextStep = useCallback(() => {
if (!canGoToStep2()) {
message.warning("请先完成出镜视频、配音和文案的选择")
const missing: string[] = []
if (!state.selectedVideo) missing.push("出镜视频")
if (!state.selectedVoice) missing.push("配音")
if (!state.scriptText.trim()) missing.push("文案")
if (missing.length > 0) {
message.warning(`请先完成${missing.join("、")}`)
return
}
setCurrentStep(2)
}, [canGoToStep2])
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handlePrevStep = useCallback(() => {
setCurrentStep(1)
}, [])
/* ── 对口型 ─ */
/* ── 对口型 ─ */
const handleGenerateLipsync = useCallback(async () => {
// ② 缺项明确提示(#1809):不再静默 return
const video = state.selectedVideo
const voice = state.selectedVoice
const text = state.scriptText.trim()
@@ -91,10 +104,12 @@ const AiAvatarPage: React.FC = () => {
return
}
try {
// 显示生成弹窗
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
console.log("[对口型] 开始生成:", {
videoId: video.id,
voiceId: voice.voice_id,
@@ -113,20 +128,19 @@ const AiAvatarPage: React.FC = () => {
message.error("获取出镜视频播放地址失败,请重新选择素材")
return
}
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
const payload = {
voice_id: voice.voice_id,
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
speed: state.speed, // 语速 0.5~2.0
emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
}
console.log("[对口型] createLipsyncJob 请求:", payload)
const job = await createLipsyncJob(payload)
console.log("[对口型] createLipsyncJob 响应:", {
id: job.id,
status: job.status,
})
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
state.setLipsyncJob(job)
// 开始轮询
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = setInterval(async () => {
try {
@@ -165,6 +179,7 @@ const AiAvatarPage: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
// 取消对口型生成
const handleCancelLipsync = useCallback(() => {
if (lipsyncTimerRef.current) {
clearInterval(lipsyncTimerRef.current)
@@ -175,13 +190,15 @@ const AiAvatarPage: React.FC = () => {
setLipsyncErrorMessage("")
}, [])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
}
}, [])
/* ── 生成视频 ── */
/* ── 生成视频(含实时进度轮询) ── */
const handleGenerate = useCallback(async () => {
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
@@ -189,15 +206,48 @@ const AiAvatarPage: React.FC = () => {
}
state.setIsGenerating(true)
try {
await submitRender({
const job = await submitRender({
lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id,
b_roll_segments: state.bRollSegments as never,
b_roll_segments: state.bRollSegments.map((seg) => ({
script_segment_index: seg.script_segment_index,
asset_url: seg.asset.file_url || "",
mode: seg.mode,
start_time: seg.start_time,
end_time: seg.end_time,
pip_position: seg.pip_position,
pip_scale: seg.pip_scale,
})) as never,
title_config: buildTitleConfigPayload(state.titleConfig),
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
resolution: state.resolution,
})
message.success("渲染任务已提交,可在视频管理中查看进度")
// 打开渲染进度弹窗,启动轮询
setShowRenderModal(true)
setRenderStatus("generating")
setRenderProgress(job.progress ?? 0)
setRenderErrorMessage("")
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = setInterval(async () => {
try {
const updated = await getRenderJob(job.id)
setRenderProgress(updated.progress ?? 0)
if (updated.status === "completed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
setRenderStatus("completed")
message.success("视频已生成并保存到成片库")
} else if (updated.status === "failed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
setRenderStatus("failed")
setRenderErrorMessage(updated.error_message || "渲染失败,请重试")
}
} catch (pollErr) {
console.error("[渲染] 轮询失败:", pollErr)
}
}, 3000)
} catch (err) {
console.error("渲染任务提交失败:", err)
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
@@ -205,17 +255,23 @@ const AiAvatarPage: React.FC = () => {
state.setIsGenerating(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
state.lipsyncJob,
state.script,
state.bRollSegments,
state.titleConfig,
state.coverConfig,
state.resolution,
])
}, [state.lipsyncJob, state.script, state.bRollSegments, state.titleConfig, state.coverConfig])
/* ── 智能封面 ── */
/* ── 关闭渲染进度弹窗 ── */
const handleCancelRender = useCallback(() => {
if (renderTimerRef.current) {
clearInterval(renderTimerRef.current)
renderTimerRef.current = null
}
setShowRenderModal(false)
setRenderStatus("generating")
setRenderProgress(0)
setRenderErrorMessage("")
}, [])
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822 ── */
const handleSmartCover = useCallback(async () => {
// 基于对口型成片抽帧,必须先完成对口型
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
@@ -258,28 +314,25 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI </h1>
<h1>AI数字人</h1>
</div>
{/* 步骤指示器 */}
<div className="aa-steps">
<div className={`aa-step ${currentStep >= 1 ? "active" : ""}`}>
<span className="aa-step__number">1</span>
<span className="aa-step__label"> / / </span>
</div>
<div className="aa-step__connector" />
<div className={`aa-step ${currentStep >= 2 ? "active" : ""}`}>
<span className="aa-step__number">2</span>
<span className="aa-step__label"> / / / </span>
</div>
{/* 步骤切换导航条 */}
<div className="aa-step-nav">
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
1. / /
</span>
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
2. / / /
</span>
</div>
<div className="aa-page-body">
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ═══ */}
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ═══ */}
{currentStep === 1 && (
<>
{/* 面板 1:出镜视频 */}
<div className={`aa-panel${collapsed.video ? " collapsed" : ""}`}>
{/* 面板1:出镜视频 */}
<div className={`aa-panel aa-panel--s1${collapsed.video ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
@@ -294,8 +347,8 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
{/* 面板 2:配音库 */}
<div className={`aa-panel${collapsed.voice ? " collapsed" : ""}`}>
{/* 面板2:配音库 */}
<div className={`aa-panel aa-panel--s1${collapsed.voice ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
@@ -316,10 +369,10 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
{/* 面板 3:文案 */}
<div className={`aa-panel${collapsed.script ? " collapsed" : ""}`}>
{/* 面板3:文案 */}
<div className={`aa-panel aa-panel--s1-wide${collapsed.script ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
@@ -328,23 +381,21 @@ const AiAvatarPage: React.FC = () => {
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
/>
<div className="aa-step-btn-row">
<button type="button" className="aa-btn aa-btn--primary" onClick={handleNextStep}>
</button>
</div>
</div>
</div>
{/* 步骤 1 底部按钮 */}
<div className="aa-step-actions">
<button className="aa-btn aa-btn--primary" onClick={handleNextStep}>
</button>
</div>
</>
)}
{/* ════ 步骤 2插入画面 / 对口型预览 / 标题配置 / 封面&生成 ═══ */}
{/* ════ 步骤 2对口型预览(含插入画面)/ 标题配置 / 封面&生成 ═══ */}
{currentStep === 2 && (
<>
{/* 面板 4:插入画面 & 对口型预览 */}
<div className={`aa-panel${collapsed.lipsync ? " collapsed" : ""}`}>
{/* 面板:对口型预览 + 插入画面 */}
<div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
@@ -356,12 +407,19 @@ const AiAvatarPage: React.FC = () => {
bRollSegments={state.bRollSegments}
onOpenBRollModal={() => state.setShowBRollModal(true)}
onRemoveBRoll={state.removeBRollSegment}
titleConfig={state.titleConfig}
onTitlePositionChange={(pos) => state.updateTitleConfig(pos)}
/>
<div className="aa-step-btn-row">
<button type="button" className="aa-btn" onClick={handlePrevStep}>
</button>
</div>
</div>
</div>
{/* 面板 5:标题配置 */}
<div className={`aa-panel${collapsed.title ? " collapsed" : ""}`}>
{/* 面板4:标题配置 */}
<div className={`aa-panel aa-panel--s2${collapsed.title ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
@@ -374,8 +432,8 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
{/* 面板 6:封面 & 生成 */}
<div className={`aa-panel${collapsed.cover ? " collapsed" : ""}`}>
{/* 面板5:封面 & 生成 */}
<div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
@@ -384,10 +442,7 @@ const AiAvatarPage: React.FC = () => {
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({
...prev,
...partial,
}))
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
@@ -400,13 +455,6 @@ const AiAvatarPage: React.FC = () => {
/>
</div>
</div>
{/* 步骤 2 底部按钮 */}
<div className="aa-step-actions">
<button className="aa-btn" onClick={handlePrevStep}>
</button>
</div>
</>
)}
</div>
@@ -465,22 +513,10 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div
style={{
marginTop: 20,
fontSize: 15,
color: "#1a1a2e",
}}
>
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#8c8ca1",
}}
>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
</div>
</>
@@ -488,13 +524,7 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "completed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
</>
@@ -502,23 +532,11 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "failed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
{lipsyncErrorMessage && (
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#ff4d4f",
}}
>
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
{lipsyncErrorMessage}
</div>
)}
@@ -540,6 +558,112 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
)}
{/* 渲染进度弹窗 */}
{showRenderModal && (
<div className="aa-modal-overlay">
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button className="aa-modal__close" onClick={handleCancelRender}>
</button>
</div>
<div
className="aa-modal__body"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "40px 20px",
}}
>
{renderStatus === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div
style={{
marginTop: 16,
fontSize: 32,
fontWeight: 700,
color: "#1890ff",
}}
>
{renderProgress}%
</div>
<div
style={{
marginTop: 12,
width: "80%",
height: 8,
backgroundColor: "#f0f0f0",
borderRadius: 4,
overflow: "hidden",
}}
>
<div
style={{
width: `${renderProgress}%`,
height: "100%",
backgroundColor: "#1890ff",
borderRadius: 4,
transition: "width 0.5s ease",
}}
/>
</div>
<div style={{ marginTop: 12, fontSize: 13, color: "#8c8ca1" }}>
</div>
</>
)}
{renderStatus === "completed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
<button
className="aa-btn"
style={{ marginTop: 16 }}
onClick={() => {
setShowRenderModal(false)
navigate("/app/products")
}}
>
📁
</button>
</>
)}
{renderStatus === "failed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}></div>
{renderErrorMessage && (
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
{renderErrorMessage}
</div>
)}
</>
)}
</div>
<div className="aa-modal__footer">
{renderStatus === "generating" && (
<button className="aa-btn aa-btn--danger" onClick={handleCancelRender}>
</button>
)}
{renderStatus !== "generating" && (
<button className="aa-btn" onClick={handleCancelRender}>
</button>
)}
</div>
</div>
</div>
)}
</div>
)
}
+2 -2
View File
@@ -54,7 +54,7 @@ export const createLipsyncJob = async (data: {
}
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
return response.data
}
@@ -66,6 +66,7 @@ export const generateSmartCover = async (
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
"/ai-avatar/render/smart-cover",
{ video_url, max_frames },
{ timeout: 60000 },
)
return response.data
}
@@ -78,7 +79,6 @@ export const submitRender = async (data: {
title_config?: Record<string, unknown>
cover_config?: Record<string, unknown>
project_id?: string
resolution?: string
}): Promise<RenderJob> => {
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
return response.data
@@ -1,8 +1,10 @@
/**
* AI数字人 — 对口型预览面板(步骤2用)
* B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮
* v3.1: 预览容器按 1/2 缩放、标题实时叠加预览
*/
import type { LipsyncJob, BRollSegment } from "../types"
import React, { useRef } from "react"
import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types"
interface PanelLipsyncPreviewProps {
lipsyncJob: LipsyncJob | null
@@ -10,6 +12,10 @@ interface PanelLipsyncPreviewProps {
bRollSegments: BRollSegment[]
onOpenBRollModal: () => void
onRemoveBRoll: (id: string) => void
/** 标题配置(实时叠加预览用) */
titleConfig?: AiAvatarTitleConfig
/** 标题位置变更回调(拖拽结束时调用) */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
}
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
@@ -29,7 +35,12 @@ export function PanelLipsyncPreview({
bRollSegments,
onOpenBRollModal,
onRemoveBRoll,
titleConfig,
onTitlePositionChange,
}: PanelLipsyncPreviewProps) {
const titleDragRef = useRef<HTMLDivElement>(null)
const draggingTitleRef = useRef(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isDone = lipsyncJob?.status === "completed"
const isFailed = lipsyncJob?.status === "failed"
@@ -41,8 +52,66 @@ export function PanelLipsyncPreview({
? "排队中…"
: "对口型生成中…"
/** 标题叠加样式 */
const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
? {
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
color: titleConfig.color || "#ffffff",
fontFamily: titleConfig.font || "思源黑体",
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
width: "90%",
padding: "4px 8px",
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
...(titleConfig.position === "top"
? { top: 8 }
: titleConfig.position === "bottom"
? { bottom: 8 }
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
}
: null
const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !previewContainerRef.current) return
e.preventDefault()
e.stopPropagation()
;(e.target as Element).setPointerCapture(e.pointerId)
draggingTitleRef.current = true
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
}
const handleTitlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !previewContainerRef.current) return
e.preventDefault()
e.stopPropagation()
if (titleDragRef.current) {
const rect = previewContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const xpct = (relX / rect.width) * 100
const ypct = (relY / rect.height) * 100
titleDragRef.current.style.left = `${xpct}%`
titleDragRef.current.style.top = `${ypct}%`
}
}
const handleTitlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
if (onTitlePositionChange && previewContainerRef.current) {
const rect = previewContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
onTitlePositionChange({ pos_x: relX, pos_y: relY })
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
}
return (
<div className="aa-lipsync-preview-panel">
<div className="aa-script-lipsync">
{/* ── B-roll 画面 ── */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title">
@@ -105,13 +174,36 @@ export function PanelLipsyncPreview({
)}
</div>
{/* ── 对口型预览 ─ */}
{/* ── 对口型预览v3.1: 缩放1/2 + 标题叠加) ─ */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title"></div>
<div className="aa-lipsync-preview">
<div className="aa-lipsync-preview" ref={previewContainerRef}>
{isDone && lipsyncJob?.output_video_url ? (
<video src={lipsyncJob.output_video_url} controls />
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<video src={lipsyncJob.output_video_url} controls />
{titleOverlayStyle && (
<div
ref={titleDragRef}
style={{
...titleOverlayStyle,
cursor: onTitlePositionChange ? "grab" : "default",
pointerEvents: onTitlePositionChange ? "auto" : "none",
}}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
{titleConfig!.title.split(/[/]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</div>
)}
</div>
) : isGenerating ? (
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
<div style={{ fontSize: 13, marginBottom: 8 }}>
@@ -20,7 +20,7 @@ export function PanelScript({
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
return (
<div className="aa-script-panel">
<div className="aa-script-lipsync">
{/* ── Tab 切换 ── */}
<div className="aa-script-tabs">
<button
+6
View File
@@ -34,9 +34,15 @@ celery_app.conf.imports = (
"worker_app.tasks.tts_synthesis",
"worker_app.tasks.batch_download",
"worker_app.tasks.duplication_check",
# #1798 AI 数字人渲染:必须在 Worker 实例上注册同名任务,否则消息无人消费(渲染卡 0%)
"worker_app.tasks.ai_avatar_render",
"worker_app.tasks._startup",
"apps.worker.video_processing.dedup",
"worker_app.tasks.cleanup",
# 注意:必须用 app.* 路径,不能用 apps.api.app.* 路径!
# PYTHONPATH=/app/apps/api 下,app.tasks.lipsync_tts 可直接导入且不触发 apps/api/__init__.py
# apps/api/__init__.py 会 from .main import app,级联加载整个 FastAPI 栈,Worker 中不需要且会导致注册失败)
"app.tasks.lipsync_tts",
)
# Celery Beat 定时任务调度
@@ -0,0 +1,48 @@
"""AI 数字人渲染任务 — Worker 侧 Celery 任务注册.
#1798 渲染进度卡在 0% 的根因:渲染任务定义在 API 侧(`app.tasks.ai_avatar_render`),
装饰在 API 自己的 Celery 实例(`xiaoxia-saas-api`)上;而 Worker 用的是
`worker_app.celery_app` 实例,`conf.imports` 从未导入该任务,Worker 的任务
注册表里没有 `ai_avatar_render.execute`,消息被路由到默认 `celery` 队列后
无人消费,任务永远停在 0%
修复:在 Worker 侧用 `worker_app.celery_app` 注册同名任务,直接调用与 API
服务一致的 `AiAvatarRenderService.execute_render` 核心管线(业务逻辑在
`apps.api.app.services`worker 镜像已复制 `apps/api/app`)。任务名保持
`ai_avatar_render.execute`,与 API 生产端 `.delay()` 的消息路由一致;未在
task_routes 显式配置,走默认 `celery` 队列,由 transcode worker 消费。
"""
from __future__ import annotations
import logging
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
def execute_ai_avatar_render(self, job_id: str) -> dict:
"""执行 AI 数字人渲染管线(Worker 侧入口).
进度由 service 直接写入 DBAiAvatarRenderJob.progress:
0→5→20→40→80→90→95→100),API 通过轮询 progress 字段展示。
"""
logger.info("开始执行渲染任务: %s", job_id)
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
session = SessionLocal()
try:
from app.services.ai_avatar_render_service import AiAvatarRenderService
service = AiAvatarRenderService(session)
service.execute_render(job_id)
return {"status": "completed", "job_id": job_id}
except Exception as exc:
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
raise
finally:
session.close()
+3 -3
View File
@@ -163,7 +163,7 @@ services:
# context: ../..
# dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile}
# args:
# NGINX_CONF: ${WEB_NGINX_CONF:-infra/docker/nginx.conf}
# (NGINX_CONF no longer needed - all configs baked into image)
container_name: xiaoxia-web-${ENV:-staging}
restart: unless-stopped
@@ -178,12 +178,12 @@ services:
- xiaoxia-net
# =========================================
# Nginx 配置运行时覆盖
# Nginx 配置运行时覆盖(双保险:entrypoint 也按 APP_ENV 选择配置)
# 确保容器使用正确环境的 nginx 配置,即使镜像构建时使用了默认配置
# 注意: 只覆盖 /etc/nginx/conf.d/default.conf,不挂载 /usr/share/nginx/html
# =========================================
environment:
- NGINX_ENV=${ENV:-staging}
- APP_ENV=${ENV:-staging}
volumes:
- ./nginx-${ENV:-staging}.conf:/etc/nginx/conf.d/default.conf:ro
+6 -15
View File
@@ -14,11 +14,11 @@ REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
REPO_DIR="${REPO_DIR:-/var/lib/xiaoxia-saas-production/repo}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
@@ -31,7 +31,6 @@ if [ -n "$REGISTRY_TOKEN" ]; then
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- Pull 三镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
@@ -66,17 +65,14 @@ if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
fi
rm -rf "$_tmpdir"
else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理超过 7 天的旧 assets 文件(避免无限增长)
if [ -d "$LEGACY_ASSETS_DIR" ]; then
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
echo "Legacy assets cleanup done (retain 7 days)"
fi
# ---- 确保基础设施容器在运行 ----
echo "Checking infrastructure containers..."
@@ -84,12 +80,10 @@ for c in xiaoxia-postgres-production xiaoxia-redis-production; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保生产网络存在 ----
@@ -108,7 +102,6 @@ echo "Migrations completed."
echo "Stopping old containers..."
docker rm -f xiaoxia-api-production 2>/dev/null || true
docker rm -f xiaoxia-worker-production 2>/dev/null || true
docker rm -f xiaoxia-web-production 2>/dev/null || true
# ---- 日志配置(所有容器共用) ----
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
@@ -166,15 +159,16 @@ docker run -d \
# ---- 启动 Web ----
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
# nginx 配置中 assets location 有 fallback 逻辑
LEGACY_VOLUME=""
WEB_VOLUMES=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
WEB_VOLUMES="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
echo "Web container: legacy assets mounted (fallback)"
else
echo "Web container: no legacy assets to mount"
fi
echo "Starting Web container..."
docker rm -f xiaoxia-web-production 2>/dev/null || true
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
@@ -182,7 +176,8 @@ docker run -d \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
-e APP_ENV=production \
$WEB_VOLUMES \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -197,7 +192,6 @@ while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
@@ -207,7 +201,6 @@ if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
docker logs --tail 50 xiaoxia-api-production
exit 1
fi
# ---- 等待 Web 健康 ----
echo "Waiting for Web to become healthy..."
@@ -216,7 +209,6 @@ while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
echo "Web is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/15)"
sleep 2
@@ -226,7 +218,6 @@ if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
docker logs --tail 30 xiaoxia-web-production
exit 1
fi
# ---- 清理旧镜像 ----
echo "Cleaning up old images..."
+2 -10
View File
@@ -124,23 +124,15 @@ docker run -d \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Web 镜像默认打包 production nginx.confstaging 需要挂载 staging 配置
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
if [ ! -f "$NGINX_CONF" ]; then
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
NGINX_VOLUME=""
else
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
fi
echo "Starting Web container..."
docker rm -f xiaoxia-web-staging 2>/dev/null || true
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
$NGINX_VOLUME \
-e APP_ENV=staging \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
-1
View File
@@ -53,7 +53,6 @@ export API_IMAGE="${API_IMAGE:-${REGISTRY}/xiaoxia-saas-api:dev}"
export WORKER_IMAGE="${WORKER_IMAGE:-${REGISTRY}/xiaoxia-saas-worker:dev}"
# Use staging-specific nginx config (proxy_pass → xiaoxia-api-staging:8000)
export WEB_NGINX_CONF=infra/docker/nginx-staging.conf
if [ "${REBUILD_BACKEND:-0}" = "1" ] || [ "${BUILD_WEB:-0}" = "1" ]; then
if [ "${ALLOW_STAGING_BUILDS:-false}" != "true" ]; then
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# Select nginx config based on APP_ENV (staging/production).
#
# 两种运行模式:
# 1. CI/CD 部署(staging/production):部署脚本通过 `-v 宿主机文件:/etc/nginx/conf.d/default.conf:ro`
# 把宿主机生成的带 resolver/docker upstream 的配置 bind mount 进来,entrypoint 不应改动。
# bind mount 的文件是 readonly 的,rm 会报 EBUSY ("Resource busy"),直接 exec nginx 即可。
# 2. 本地 docker-compose / 直接 `docker run`(无外部挂载):镜像烤入了 nginx-staging.conf 与
# nginx-production.conf 到 /etc/nginx/entrypoint 根据 APP_ENV 把 default.conf 换成正确的 symlink。
#
# 策略:
# - 如果 /etc/nginx/conf.d/default.conf 已经是指向目标 conf 的 symlink,什么都不做;
# - 否则尝试 rm -f 再 ln -srm 失败说明是外部 bind mount(已有正确配置),不阻塞启动;
# - 兜底:只要 conf.d 目录里有 .conf 文件(含 bind mount 来的),就直接启动 nginx。
set -e
NGINX_CONF_DIR="/etc/nginx/conf.d"
TARGET_CONF=""
case "${APP_ENV:-production}" in
staging)
TARGET_CONF="/etc/nginx/nginx-staging.conf"
;;
*)
TARGET_CONF="/etc/nginx/nginx-production.conf"
;;
esac
DEFAULT_CONF="$NGINX_CONF_DIR/default.conf"
# 1. 已经是正确的 symlink:直接启动
if [ -L "$DEFAULT_CONF" ] && [ "$(readlink "$DEFAULT_CONF" 2>/dev/null)" = "$TARGET_CONF" ]; then
exec nginx -g "daemon off;"
fi
# 2. 尝试替换为目标 symlink(无 bind mount 的场景)
# 若 rm 失败(bind mount readonlyEBUSY/EPERM),则认为外部已注入配置,不阻塞。
rm -f "$DEFAULT_CONF" 2>/dev/null || true
if [ -f "$TARGET_CONF" ] && [ ! -e "$DEFAULT_CONF" ]; then
ln -s "$TARGET_CONF" "$DEFAULT_CONF" 2>/dev/null || true
fi
# 3. 兜底:至少要有一个 .conf 文件,否则 nginx 起不来
if ! ls "$NGINX_CONF_DIR"/*.conf >/dev/null 2>&1; then
echo "ERROR: no nginx config found in $NGINX_CONF_DIR (tried $TARGET_CONF and external bind mount)" >&2
exit 1
fi
exec nginx -g "daemon off;"
+7 -3
View File
@@ -1,7 +1,11 @@
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY apps/web/dist ./
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
# 将所有 nginx 配置烤入镜像,entrypoint 按 APP_ENV 选择
COPY infra/docker/nginx.conf /etc/nginx/nginx-production.conf
COPY infra/docker/nginx-staging.conf /etc/nginx/nginx-staging.conf
COPY infra/docker/nginx-production.conf /etc/nginx/nginx-production.conf
COPY infra/docker/nginx-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/docker-entrypoint.sh"]
+7 -3
View File
@@ -28,9 +28,13 @@ RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
# Production stage with nginx
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/apps/web/dist ./
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
# 将所有 nginx 配置烤入镜像,entrypoint 按 APP_ENV 选择
COPY infra/docker/nginx.conf /etc/nginx/nginx-production.conf
COPY infra/docker/nginx-staging.conf /etc/nginx/nginx-staging.conf
COPY infra/docker/nginx-production.conf /etc/nginx/nginx-production.conf
COPY infra/docker/nginx-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/docker-entrypoint.sh"]
+5 -3
View File
@@ -20,7 +20,7 @@ WORKDIR /app
# 设置 Python 环境变量
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH=/app:/app/packages
ENV PYTHONPATH=/app:/app/apps/api:/app/packages
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
@@ -28,8 +28,10 @@ ENV APP_VERSION=$APP_VERSION
COPY alembic.ini /app/alembic.ini
COPY migrations/ /app/migrations/
COPY packages/ /app/packages/
COPY apps/api/app/config.py /app/apps/api/app/config.py
COPY apps/api/app/core/ /app/apps/api/app/core/
# PR #1844 起,worker 还需要加载 apps.api.app.tasks.lipsync_tts
# 该 task 依赖 app.services.* 与 app.core.celery_appPYTHONPATH=/app/apps/api 下解析)。
# 为避免后续新增 task 再次漏 COPY,直接把整个 apps/api/app/ 复制进 worker 镜像。
COPY apps/api/app/ /app/apps/api/app/
# Worker 启动脚本
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
+1
View File
@@ -0,0 +1 @@
# xiaoxia-saas shared packages namespace
+1
View File
@@ -0,0 +1 @@
# adapter implementations namespace
+43 -6
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
"""CI中自动修复代码格式(Python: black + isort + ruff | Frontend: prettier),并推送回原分支。
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
- black/isort/prettier 修格式;ruff check --fix --unsafe-fixes 自动修复
ruff 可修复的 lint 规则(含 F401 未使用 import 等 unsafe fix
- ruff 目标范围与 validate_style.sh 的检查范围对齐:apps packages tests
alembic/scripts 不在 ruff 检查范围内,不做修复)
当code quality检查因格式问题失败时触发。
"""
@@ -135,7 +138,7 @@ def get_pr_head_branch(pr_number, api_url, token):
def fix_python(target_py_files, scan_mode):
"""修复 Python 文件格式 (black + isort)"""
"""修复 Python 文件 (black 格式化 + isort 排序 + ruff lint 自动修复)"""
if not target_py_files:
print("没有需要修复的 Python 文件,跳过")
return
@@ -146,14 +149,48 @@ def fix_python(target_py_files, scan_mode):
result = run(f"python3 -m black {target_str}", check=False)
print(result.stdout[-500:] if result.stdout else "")
if result.returncode != 0:
print("black执行失败,但继续尝试isort", file=sys.stderr)
print("black执行失败,但继续尝试isort/ruff", file=sys.stderr)
print()
print("--- isort 排序 ---")
result = run(f"python3 -m isort {target_str}", check=False)
print(result.stdout[-500:] if result.stdout else "")
if result.returncode != 0:
print("isort执行失败", file=sys.stderr)
print("isort执行失败,继续尝试ruff", file=sys.stderr)
# ruff lint 自动修复
# 与 validate_style.sh 的检查范围对齐:只修 apps/packages/tests
# alembic 在 pyproject.toml 中被 excludescripts 不在 ruff 检查范围内)
ruff_scopes = ("apps/", "packages/", "tests/")
ruff_files = [f for f in target_py_files if f.startswith(ruff_scopes)]
if scan_mode != "incremental":
ruff_targets = "apps packages tests"
elif ruff_files:
ruff_targets = " ".join(ruff_files)
else:
ruff_targets = ""
if ruff_targets:
# ruff 由 style job 的 requirements-dev.txt 安装;不可用时跳过(不阻断 black/isort 的修复)
avail = run("python3 -m ruff --version", check=False)
if avail.returncode != 0:
print("ruff 不可用,跳过 ruff 自动修复", file=sys.stderr)
else:
print()
print("--- ruff lint 自动修复 (--fix --unsafe-fixes) ---")
# --unsafe-fixes 用于启用 F401(未使用 import)等 ruff 归类为 unsafe 的自动修复;
# 安全性由修复后重跑的完整 CI(单测/构建/staging 健康检查)兜底
result = run(
f"python3 -m ruff check {ruff_targets} --fix --unsafe-fixes",
check=False,
)
print(result.stdout[-1500:] if result.stdout else "")
if result.returncode != 0:
# 可能是仍有不可自动修复的 lint 错误(留待 style check 再次拦截),或修复过程出错
print("ruff 自动修复后仍有未修复项或执行失败,剩余问题由 style check 继续拦截", file=sys.stderr)
else:
print()
print("增量模式且无 apps/packages/tests 范围内的 Python 变更,跳过 ruff 自动修复")
def fix_frontend(target_fe_files, scan_mode, repo_root):
@@ -334,7 +371,7 @@ def main():
# 提交修复
run("git clean -fd")
run("git add -u")
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
run('git commit -m "style: auto-format with black + isort + ruff + prettier [skip ci-format-check]"')
# 推送(head_branch已从ensure_git_repo获取)
print(f"\nPR来源分支: {head_branch}")
@@ -132,14 +132,8 @@ def _lipsync_service_with_mocks():
def test_create_job_tts_direct_mode_synthesizes_audio():
svc, client, cosy = _lipsync_service_with_mocks()
with (
patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch,
patch("app.services.lipsync_service.safe_download_bytes") as dl_patch,
):
storage = MagicMock()
storage.upload_file.return_value = "https://oss/tts.mp3"
storage_patch.return_value = storage
dl_patch.return_value = b"FAKEAUDIO"
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async.return_value = MagicMock(id="celery-task-123")
job = svc.create_job(
user_id="user-1",
@@ -150,19 +144,16 @@ def test_create_job_tts_direct_mode_synthesizes_audio():
emotion="兴奋",
)
# 调了 TTS 合成,带 speed/emotion
cosy.submit_synthesize_task.assert_called_once()
_, kwargs = cosy.submit_synthesize_task.call_args
assert kwargs["speed"] == 1.2
assert kwargs["emotion"] == "excited"
assert kwargs["voice_id"] == "cosy-v1"
# MediaKit 用合成后的 OSS 音频 URL 提交
_, submit_kwargs = client.submit_lipsync.call_args
assert submit_kwargs["audio_url"] == "https://oss/tts.mp3"
assert submit_kwargs["video_url"] == "https://oss/person.mp4"
# DB 记录了 TTS 字段
# v4: TTS 模式下 create_job 返回 tts_processing 状态,dispatch Celery 任务
assert job.status == "tts_processing"
assert job.emotion == "excited"
assert job.speed == 1.2
# 不直接调用 CosyVoice(由 Celery 任务处理)
cosy.submit_synthesize_task.assert_not_called()
# 不直接提交 MediaKit(由 Celery 任务处理)
client.submit_lipsync.assert_not_called()
# dispatch 了 Celery 任务
mock_task.apply_async.assert_called_once()
def test_create_job_direct_audio_mode_skips_tts():
@@ -180,23 +171,25 @@ def test_create_job_direct_audio_mode_skips_tts():
def test_create_job_tts_failure_raises():
from app.services.mediakit_client import MediaKitError
from packages.application.cosyvoice_service import CosyVoiceError
"""v4: TTS 模式下 create_job 不再同步失败,而是 dispatch Celery 任务。
TTS 合成失败由 Celery 任务内部处理并更新 job 状态。"""
svc, client, cosy = _lipsync_service_with_mocks()
cosy.submit_synthesize_task.side_effect = CosyVoiceError("Arrearage")
with pytest.raises(MediaKitError) as exc:
svc.create_job(
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async.return_value = MagicMock(id="celery-task-456")
job = svc.create_job(
user_id="user-1",
video_url="https://oss/person.mp4",
voice_id="v-1",
script_text="文本",
)
assert exc.value.code == "TTSSynthesisFailed"
# TTS 失败不应提交 MediaKit
# create_job 成功返回 tts_processing,不直接调用 TTS
assert job.status == "tts_processing"
cosy.submit_synthesize_task.assert_not_called()
client.submit_lipsync.assert_not_called()
mock_task.apply_async.assert_called_once()
# ── refresh 同步中间状态 ────────────────────────────────────────────────
@@ -245,24 +238,32 @@ def test_smart_cover_selects_best_frame_and_persists():
{"url": "https://mk/f1.jpg", "score": 90.0, "image_path": cands[1]["image_path"]},
{"url": "https://mk/f0.jpg", "score": 60.0, "image_path": cands[0]["image_path"]},
]
# httpx.Client 连接池 mock
client_instance = MagicMock()
resp = MagicMock()
resp.content = b"IMGDATA"
resp.raise_for_status = MagicMock()
client_instance = MagicMock()
client_instance.get.return_value = resp
client_instance.__enter__ = MagicMock(return_value=client_instance)
client_instance.__exit__ = MagicMock(return_value=False)
http_client_cls.return_value = client_instance
storage = MagicMock()
storage.public_url = "https://other-oss.example.com" # 不同host,不触发重签
storage.upload_file.return_value = "https://oss/cover.jpg"
storage.public_url = "https://oss.example.com"
# video_url 不是自家 OSS,不重签
storage.get_download_url.side_effect = lambda url, **kw: f"{url}?signed=1"
storage.upload_file.return_value = "https://oss.example.com/cover.jpg"
storage_patch.return_value = storage
url = cov.generate_smart_cover("https://oss/avatar.mp4", job_id="job-1")
url = cov.generate_smart_cover("https://other-host/avatar.mp4", job_id="job-1")
assert url == "https://oss/cover.jpg"
assert "signed=1" in url or url == "https://oss.example.com/cover.jpg"
mk.extract_frames.assert_called_once()
score_patch.assert_called_once()
# 验证使用了增大的轮询参数
call_kwargs = mk.extract_frames.call_args
assert call_kwargs.kwargs.get("poll_interval") == 3.0 or call_kwargs[1].get("poll_interval") == 3.0
assert call_kwargs.kwargs.get("max_poll_attempts") == 20 or call_kwargs[1].get("max_poll_attempts") == 20
def test_smart_cover_returns_empty_when_mediakit_unavailable():
@@ -276,47 +277,45 @@ def test_smart_cover_returns_empty_when_mediakit_unavailable():
mk.is_available = False
mk_patch.return_value = mk
storage = MagicMock()
storage.public_url = "https://other-oss.example.com"
storage.public_url = "https://oss.example.com"
storage_patch.return_value = storage
url = cov.generate_smart_cover("https://oss/avatar.mp4")
url = cov.generate_smart_cover("https://oss.example.com/avatar.mp4")
assert url == ""
def test_sign_video_url_resigns_own_oss_url():
"""自家 OSS 私有桶 URL 被重签为长有效期预签名 URL"""
from app.services import ai_avatar_cover_service as cov
"""自家 OSS 私有桶 URL 被重签为长有效期预签名 URL"""
from app.services.ai_avatar_cover_service import _sign_video_url_for_mediakit
with patch("packages.shared.storage.get_shared_storage_service") as storage_patch:
storage = MagicMock()
storage.public_url = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
storage.get_download_url.return_value = (
"https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/vid.mp4?Expires=999&Signature=abc"
)
storage.public_url = "https://oss.example.com"
storage.get_download_url.return_value = "https://oss.example.com/file.mp4?Expires=xxx&Signature=yyy"
storage_patch.return_value = storage
result = cov._sign_video_url_for_mediakit("https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/vid.mp4")
result = _sign_video_url_for_mediakit("https://oss.example.com/file.mp4")
assert "Signature=abc" in result
assert "Signature=yyy" in result
storage.get_download_url.assert_called_once()
def test_sign_video_url_skips_external_url():
"""外部 URL 不会被重签"""
from app.services import ai_avatar_cover_service as cov
"""外部 URL(非自家 OSS)应原样返回,不做重签"""
from app.services.ai_avatar_cover_service import _sign_video_url_for_mediakit
with patch("packages.shared.storage.get_shared_storage_service") as storage_patch:
storage = MagicMock()
storage.public_url = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
storage.public_url = "https://oss.example.com"
storage_patch.return_value = storage
result = cov._sign_video_url_for_mediakit("https://cdn.example.com/video.mp4")
result = _sign_video_url_for_mediakit("https://external-cdn.com/video.mp4")
assert result == "https://cdn.example.com/video.mp4"
assert result == "https://external-cdn.com/video.mp4"
storage.get_download_url.assert_not_called()
def test_extract_frames_uses_extended_poll_params():
"""验证抽帧使用增大后的轮询参数"""
"""验证 select_best_cover_frame 使用增大后的轮询参数"""
from app.services import ai_avatar_cover_service as cov
with (
@@ -327,16 +326,17 @@ def test_extract_frames_uses_extended_poll_params():
mk.is_available = True
mk.extract_frames.return_value = [{"image_url": "https://mk/f0.jpg"}]
mk_patch.return_value = mk
storage = MagicMock()
storage.public_url = "https://other-oss.example.com"
storage.public_url = "https://oss.example.com"
storage_patch.return_value = storage
cov.select_best_cover_frame("https://oss/video.mp4", max_frames=3)
cov.select_best_cover_frame("https://other/avatar.mp4", max_frames=3)
call_kwargs = mk.extract_frames.call_args.kwargs
assert call_kwargs["poll_interval"] == 3.0
assert call_kwargs["max_poll_attempts"] == 20
assert call_kwargs["max_retries"] == 1
call_kwargs = mk.extract_frames.call_args
assert call_kwargs.kwargs.get("poll_interval") == 3.0 or call_kwargs[1].get("poll_interval") == 3.0
assert call_kwargs.kwargs.get("max_poll_attempts") == 20 or call_kwargs[1].get("max_poll_attempts") == 20
assert call_kwargs.kwargs.get("max_retries") == 1 or call_kwargs[1].get("max_retries") == 1
# ── 渲染 script_id 可选(手动文案直生场景)──────────────────────────────
@@ -519,6 +519,103 @@ class TestAiAvatarRenderService:
# 不应执行渲染逻辑
mock_db.commit.assert_not_called()
def test_execute_render_success_creates_clip_record(self):
"""execute_render 完成后自动创建成片记录到成片库."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(
status="completed",
output_video_url="https://oss/lipsync.mp4",
output_duration=30.0,
)
mock_job = _make_mock_render_job(
job_id="render-ok",
status="pending",
output_video_url="",
output_cover_url="",
output_duration=0.0,
)
mock_filter = MagicMock()
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("os.system", return_value=0),
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch(
"app.services.ai_avatar_cover_service.generate_smart_cover", return_value="https://oss/smart_cover.jpg"
),
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
import tempfile as _tf
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
mock_clip = MagicMock()
mock_clip.id = "clip-001"
gv_create.return_value = mock_clip
mock_repo = MagicMock()
repo_cls.return_value = mock_repo
svc.execute_render("render-ok")
assert mock_job.status == "completed"
gv_create.assert_called_once()
call_kwargs = gv_create.call_args
assert "https://oss/" in call_kwargs.kwargs["file_url"]
assert call_kwargs.kwargs["user_id"] == "user-1"
mock_repo.create.assert_called_once_with(mock_clip)
def test_execute_render_clip_failure_does_not_affect_render(self):
"""成片创建失败不影响渲染任务标记为成功."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(
status="completed",
output_video_url="https://oss/lipsync.mp4",
output_duration=30.0,
)
mock_job = _make_mock_render_job(
job_id="render-clip-fail",
status="pending",
output_video_url="",
output_cover_url="",
output_duration=0.0,
)
mock_filter = MagicMock()
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("os.system", return_value=0),
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch("app.services.ai_avatar_cover_service.generate_smart_cover", side_effect=RuntimeError("DB error")),
):
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
svc.execute_render("render-clip-fail")
# 即使成片创建失败,渲染任务仍应标记为 completed
assert mock_job.status == "completed"
def test_error_exception_has_code(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError
+164 -54
View File
@@ -208,24 +208,21 @@ class TestLipsyncServiceUnit:
"""Service 层单元测试(纯 mock,不依赖数据库)— #1809 更新."""
def test_create_job_success(self, mock_mediakit, mock_cosyvoice):
"""v3: TTS 直生——service 内部 submit_synthesize_task 合成后转存 OSS,再提交 MediaKit."""
"""TTS 直生——v4 异步模式:create_job 只创建 DB 记录 + dispatch Celery 任务."""
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
mock_repo = MagicMock()
mock_repo.get.return_value = None # 预置音色,原样返回 voice_id
with (
patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch,
patch("app.services.lipsync_service.safe_download_bytes") as dl_patch,
):
storage_patch.return_value.upload_file.return_value = "https://my-oss/tts.mp3"
dl_patch.return_value = b"audio-bytes"
with patch(
"app.services.lipsync_service.tts_synthesize_and_submit"
) as mock_task:
mock_task.apply_async.return_value = MagicMock(id="celery-task-123")
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
@@ -238,32 +235,23 @@ class TestLipsyncServiceUnit:
emotion="兴奋",
)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-task-123"
# TTS 直生走 submit_synthesize_task,带语速/情绪
mock_cosyvoice.submit_synthesize_task.assert_called_once()
_, kwargs = mock_cosyvoice.submit_synthesize_task.call_args
assert kwargs["text"] == "大家好,欢迎来到直播间"
assert kwargs["voice_id"] == "longxiaochun_v3"
assert kwargs["speed"] == 1.2
assert kwargs["emotion"] == "excited" # 兴奋→excited
# TTS 模式:异步返回,状态为 tts_processing
assert job.status == "tts_processing"
assert not job.audio_url # TTS 音频尚未合成(默认空字符串)
# 不直接调用 CosyVoice
mock_cosyvoice.submit_synthesize_task.assert_not_called()
# dispatch 了 Celery 任务
mock_task.apply_async.assert_called_once()
# job 记录透传字段
assert job.speed == 1.2
assert job.emotion == "excited"
# MediaKit 用转存后的 OSS audio_url
call_kwargs = mock_mediakit.submit_lipsync.call_args
assert call_kwargs.kwargs["audio_url"] == "https://my-oss/tts.mp3"
# MediaKit 尚未提交(由 Celery 任务处理)
mock_mediakit.submit_lipsync.assert_not_called()
def test_create_job_tts_failure(self, mock_mediakit):
"""v3: TTS 合成失败时,CosyVoiceError 被包装为 MediaKitError(TTSSynthesisFailed)
在建 DB 记录之前抛出,不提交 MediaKit"""
"""v4: TTS 模式下 create_job 不再同步失败,而是 dispatch Celery 任务。
TTS 合成失败由 Celery 任务内部处理(见 test_lipsync_speed_optimization.py"""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from packages.application.cosyvoice_service import CosyVoiceError
mock_cosyvoice = MagicMock()
mock_cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("Arrearage 欠费")
mock_db = MagicMock()
mock_repo = MagicMock()
@@ -272,38 +260,41 @@ class TestLipsyncServiceUnit:
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
with patch(
"app.services.lipsync_service.tts_synthesize_and_submit"
) as mock_task:
mock_task.apply_async.return_value = MagicMock(id="celery-task-456")
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
assert exc_info.value.code == "TTSSynthesisFailed"
# 不应提交到 MediaKit
# TTS 模式下 create_job 成功返回,状态为 tts_processing
assert job.status == "tts_processing"
mock_mediakit.submit_lipsync.assert_not_called()
mock_task.apply_async.assert_called_once()
def test_create_job_api_failure(self, mock_mediakit, mock_cosyvoice):
"""MediaKit 提交失败."""
def test_create_job_api_failure(self, mock_mediakit):
"""MediaKit 提交失败(直传音频模式同步触发)."""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
svc = LipsyncService(mock_db, client=mock_mediakit)
with pytest.raises(MediaKitError, match="API 调用失败"):
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="测试文本",
audio_url="https://example.com/audio.mp3",
)
def test_get_job_delegates_to_db(self, mock_mediakit, mock_cosyvoice):
@@ -434,24 +425,21 @@ class TestLipsyncServiceUnit:
assert result.status == "completed"
def test_create_job_stores_tts_audio_url(self, mock_mediakit, mock_cosyvoice):
"""v3: TTS 直生模式下 job.audio_url 为转存到自家 OSS 的永久地址."""
"""v4: TTS 模式下 create_job 返回 tts_processing 状态,audio_url 尚未设置(由 Celery 任务处理)."""
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
mock_repo = MagicMock()
mock_repo.get.return_value = None
with (
patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch,
patch("app.services.lipsync_service.safe_download_bytes") as dl_patch,
):
storage_patch.return_value.upload_file.return_value = "https://my-oss/permanent.mp3"
dl_patch.return_value = b"audio-bytes"
with patch(
"app.services.lipsync_service.tts_synthesize_and_submit"
) as mock_task:
mock_task.apply_async.return_value = MagicMock(id="celery-task-789")
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
job = svc.create_job(
@@ -461,8 +449,11 @@ class TestLipsyncServiceUnit:
script_text="这是一段测试文本",
)
# job.audio_url 是转存 OSS 后的永久地址
assert job.audio_url == "https://my-oss/permanent.mp3"
# TTS 模式下 create_job 返回 tts_processing 状态
assert job.status == "tts_processing"
# audio_url 尚未设置(由 Celery 任务异步处理),模型默认为空字符串
assert not job.audio_url
mock_task.apply_async.assert_called_once()
def test_create_job_direct_audio_skips_tts(self, mock_mediakit, mock_cosyvoice):
"""v3: 直接音频模式(传 audio_url)不触发 TTS,原样把 audio_url 提交 MediaKit."""
@@ -548,9 +539,9 @@ class TestErrorHandling:
assert exc_info.value.code == "VoiceNotReady"
def test_tts_value_error_mapped_to_invalid_param(self, mock_mediakit):
"""v3: CosyVoice 抛 ValueError(参数无效)被包装为 TTSInvalidParam(路由映射 400."""
"""v4: TTS 模式下 create_job 不再同步调用 CosyVoice
而是 dispatch Celery 任务。ValueError 由 Celery 任务内部处理。"""
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_cosyvoice = MagicMock()
mock_cosyvoice.submit_synthesize_task.side_effect = ValueError("voice_id 为空")
@@ -562,18 +553,27 @@ class TestErrorHandling:
svc = LipsyncService(
mock_db,
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=mock_repo,
)
with pytest.raises(MediaKitError) as exc_info:
svc.create_job(
with patch(
"app.services.lipsync_service.tts_synthesize_and_submit"
) as mock_task:
mock_task.apply_async.return_value = MagicMock(id="celery-task-789")
# TTS 模式下 create_job 不再同步失败
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="some-voice",
script_text="test",
)
assert exc_info.value.code == "TTSInvalidParam"
# 确认返回 tts_processing 状态
assert job.status == "tts_processing"
# TTS 合成由 Celery 任务处理,不直接调用 CosyVoice
mock_cosyvoice.submit_synthesize_task.assert_not_called()
mock_task.apply_async.assert_called_once()
def test_missing_both_inputs_raises_invalid_input(self, mock_mediakit, mock_cosyvoice):
"""v3: 既无 audio_url 又无 voice_id+script_text 时抛 InvalidInput(路由映射 400."""
@@ -596,3 +596,113 @@ class TestErrorHandling:
assert exc_info.value.code == "InvalidInput"
mock_cosyvoice.submit_synthesize_task.assert_not_called()
mock_mediakit.submit_lipsync.assert_not_called()
class TestSignMediaUrl403Fix:
"""#1839 私有桶 OSS URL 重签:MediaKit GPU worker 拉取裸/过期 URL 会 403.
- 自家 OSS 的裸 public_url / 已过期短预签名 → 重签 7 天长有效期
- 外部临时 URLCosyVoice/MediaKit)→ 原样透传
- 签名异常 → 降级原 URL,不阻断
"""
OSS_PUBLIC_BASE = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
def _svc(self, mock_mediakit, mock_cosyvoice):
from app.services.lipsync_service import LipsyncService
return LipsyncService(
MagicMock(),
client=mock_mediakit,
cosyvoice_service=mock_cosyvoice,
voice_clone_repo=MagicMock(),
)
def test_own_oss_unsigned_url_gets_resigned(self, mock_mediakit, mock_cosyvoice):
"""裸 public_url(不带签名,私有桶匿名 403)必须被重签."""
from app.services.lipsync_service import MEDIAKIT_URL_TTL_SECONDS
svc = self._svc(mock_mediakit, mock_cosyvoice)
raw = f"{self.OSS_PUBLIC_BASE}/lipsync-tts/user-1/audio.mp3"
signed = raw + "?Expires=999&Signature=abc&OSSAccessKeyId=key"
storage = MagicMock()
storage.public_url = self.OSS_PUBLIC_BASE
storage.get_download_url.return_value = signed
with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage):
out = svc._sign_media_url(raw)
assert out == signed
storage.get_download_url.assert_called_once_with(raw, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
assert MEDIAKIT_URL_TTL_SECONDS == 7 * 24 * 3600
def test_own_oss_expired_presign_gets_resigned(self, mock_mediakit, mock_cosyvoice):
"""已带过期签名的旧预签名 URL 也要抽 key 后重签(不把旧 query 带进新签名)."""
svc = self._svc(mock_mediakit, mock_cosyvoice)
old = f"{self.OSS_PUBLIC_BASE}/avatar/video.mp4?Expires=111&Signature=old"
fresh = f"{self.OSS_PUBLIC_BASE}/avatar/video.mp4?Expires=999&Signature=fresh"
storage = MagicMock()
storage.public_url = self.OSS_PUBLIC_BASE
storage.get_download_url.return_value = fresh
with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage):
out = svc._sign_media_url(old)
assert out == fresh
# 传给 get_download_url 的是原始完整 URL(内部抽 key),有效期 7 天
called_url = storage.get_download_url.call_args.args[0]
assert called_url == old
def test_external_url_passthrough(self, mock_mediakit, mock_cosyvoice):
"""CosyVoice/MediaKit 外部临时链接不处理,原样透传."""
svc = self._svc(mock_mediakit, mock_cosyvoice)
external = "https://cv-tts.cosyvoice.aliyuncs.com/output/x.mp3"
storage = MagicMock()
storage.public_url = self.OSS_PUBLIC_BASE
with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage):
out = svc._sign_media_url(external)
assert out == external
storage.get_download_url.assert_not_called()
def test_empty_url_returns_empty(self, mock_mediakit, mock_cosyvoice):
svc = self._svc(mock_mediakit, mock_cosyvoice)
assert svc._sign_media_url("") == ""
def test_sign_error_falls_back_to_raw(self, mock_mediakit, mock_cosyvoice):
"""签名抛异常时降级返回原 URL,不阻断对口型提交."""
svc = self._svc(mock_mediakit, mock_cosyvoice)
raw = f"{self.OSS_PUBLIC_BASE}/lipsync-tts/u/a.mp3"
storage = MagicMock()
storage.public_url = self.OSS_PUBLIC_BASE
storage.get_download_url.side_effect = RuntimeError("oss down")
with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage):
out = svc._sign_media_url(raw)
assert out == raw
def test_create_job_resigns_oss_urls_before_submit(self, mock_mediakit, mock_cosyvoice):
"""端到端:create_job 提交 MediaKit 前,自家 OSS 的 video_url 必须是重签后的长签名 URL."""
svc = self._svc(mock_mediakit, mock_cosyvoice)
raw_video = f"{self.OSS_PUBLIC_BASE}/avatar/person.mp4"
signed_video = raw_video + "?Expires=999&Signature=fresh"
raw_audio = f"{self.OSS_PUBLIC_BASE}/direct/audio.mp3"
signed_audio = raw_audio + "?Expires=999&Signature=afresh"
storage = MagicMock()
storage.public_url = self.OSS_PUBLIC_BASE
storage.get_download_url.side_effect = lambda u, expires_seconds=0: (
signed_video if u == raw_video else signed_audio
)
with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage):
svc.create_job(
user_id="user-1",
video_url=raw_video,
audio_url=raw_audio,
)
kw = mock_mediakit.submit_lipsync.call_args.kwargs
assert kw["video_url"] == signed_video
assert kw["audio_url"] == signed_audio
@@ -0,0 +1,286 @@
"""AI 数字人口型视频生成速度优化 — 单元测试.
验证两个优化点:
1. FFmpeg 编码 preset 从 fast 改为 veryfast(提速 30~50%
2. TTS 合成从同步改为 Celery 异步任务(API 响应从 6~35s 降到 <1s
Issue: lipsync-speed-optimization
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
# ═══════════════════════════════════════════════════════════════════════════════
# 优化1: FFmpeg 编码提速 — preset veryfast
# ═══════════════════════════════════════════════════════════════════════════════
class TestFFmpegPresetOptimization:
"""验证 FFmpeg 编码命令从 -preset fast 改为 -preset veryfast."""
def test_preset_is_veryfast(self):
"""_build_ffmpeg_command 输出必须包含 -preset veryfast."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
cmd = svc._build_ffmpeg_command(
input_video="https://example.com/video.mp4",
b_roll_segments=[],
filter_complex="",
final_label=None,
output_path="/tmp/output.mp4",
)
assert "-preset veryfast" in cmd, f"期望 -preset veryfast,实际命令: {cmd}"
def test_preset_veryfast_with_filter(self):
"""带滤镜场景下也必须使用 veryfast."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
cmd = svc._build_ffmpeg_command(
input_video="https://example.com/video.mp4",
b_roll_segments=[],
filter_complex="overlay=0:0",
final_label="[v]",
output_path="/tmp/output.mp4",
)
assert "-preset veryfast" in cmd
assert "-filter_complex" in cmd
def test_preset_not_fast(self):
"""确保不再使用旧的 -preset fast."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
cmd = svc._build_ffmpeg_command(
input_video="https://example.com/video.mp4",
b_roll_segments=[],
filter_complex="",
final_label=None,
output_path="/tmp/output.mp4",
)
# 确保是 veryfast 而不是 fast
assert "-preset veryfast" in cmd
# 排除 "fast" 单独出现(veryfast 包含 fast 子串,需精确判断)
parts = cmd.split()
preset_idx = parts.index("-preset")
assert parts[preset_idx + 1] == "veryfast"
# ═══════════════════════════════════════════════════════════════════════════════
# 优化2: TTS 合成 Celery 异步化
# ═══════════════════════════════════════════════════════════════════════════════
def _make_service_with_mocks():
"""构造 LipsyncService 测试实例及 mock 依赖."""
from app.services.lipsync_service import LipsyncService
db = MagicMock()
client = MagicMock()
client.is_available = True
client.submit_lipsync.return_value = {
"success": True,
"task_id": "mk-1",
"request_id": "req-1",
}
cosy = MagicMock()
cosy.submit_synthesize_task.return_value = {
"audio_url": "https://tts/raw.mp3",
"request_id": "tts-req",
"audio_duration": 3.0,
}
svc = LipsyncService(db, client=client, cosyvoice_service=cosy, voice_clone_repo=MagicMock())
# _resolve_voice_id 默认原样返回(repo.get 返回 None
svc._voice_clone_repo.get.return_value = None
return svc, client, cosy
class TestCreateJobAsyncTTS:
"""验证 TTS 模式改为 Celery 异步后的行为."""
def test_tts_mode_returns_tts_processing_status(self):
"""TTS 模式下 create_job 立即返回,状态为 tts_processing."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="大家好",
speed=1.0,
emotion="",
)
assert job.status == "tts_processing"
def test_tts_mode_dispatches_celery_task(self):
"""TTS 模式必须 dispatch Celery 异步任务."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock()
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试文本",
)
mock_task.apply_async.assert_called_once()
call_kwargs = mock_task.apply_async.call_args
args = call_kwargs.kwargs.get("args") or call_kwargs[1].get("args", call_kwargs[0][0] if call_kwargs[0] else ())
assert args[1] == "user-1" # user_id
assert args[2] == "v-1" # voice_id
assert args[3] == "测试文本" # script_text
def test_tts_mode_celery_dispatch_failure_marks_job_failed(self):
"""Celery dispatch 失败时,job 标为 failed 并写入 error_message,前端轮询能直接看到错误."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock(side_effect=Exception("Celery broker down"))
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试文本",
)
# job 已创建且状态标为 failed
assert job is not None
assert job.status == "failed"
assert "Celery 任务投递失败" in job.error_message
assert job.error_code == "AsyncDispatchFailed"
# MediaKit 未被调用
client.submit_lipsync.assert_not_called()
def test_tts_mode_voice_validation_still_sync(self):
"""TTS 模式下音色校验仍在 HTTP 请求中同步执行."""
from app.services.mediakit_client import MediaKitError
svc, client, cosy = _make_service_with_mocks()
# 模拟音色属于其他用户
other_profile = MagicMock()
other_profile.user_id = "user-other"
svc._voice_clone_repo.get.return_value = other_profile
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit"):
with pytest.raises(MediaKitError) as exc:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="clone-profile-id",
script_text="测试",
)
assert exc.value.code == "VoiceForbidden"
def test_tts_mode_missing_input_raises_immediately(self):
"""缺少 voice_id 或 script_text 时立即报错,不 dispatch Celery 任务."""
from app.services.mediakit_client import MediaKitError
svc, client, cosy = _make_service_with_mocks()
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit") as mock_task:
mock_task.delay = MagicMock()
with pytest.raises(MediaKitError) as exc:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
# 缺少 voice_id 和 script_text
)
assert exc.value.code == "InvalidInput"
# Celery 任务未被 dispatch
mock_task.delay.assert_not_called()
# TTS 和 MediaKit 均未调用
cosy.submit_synthesize_task.assert_not_called()
client.submit_lipsync.assert_not_called()
class TestCreateJobDirectAudio:
"""验证直接音频模式不受异步化影响."""
def test_direct_audio_still_submits_synchronously(self):
"""直接音频模式仍然同步提交 MediaKit,状态为 submitted."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit") as mock_task:
mock_task.delay = MagicMock()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-1"
client.submit_lipsync.assert_called_once()
# TTS Celery 任务不应被调用
mock_task.delay.assert_not_called()
def test_direct_audio_skips_tts(self):
"""直接音频模式不调用 CosyVoice TTS."""
svc, client, cosy = _make_service_with_mocks()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
cosy.submit_synthesize_task.assert_not_called()
call_kwargs = client.submit_lipsync.call_args
assert call_kwargs.kwargs["audio_url"] == "https://example.com/audio.mp3"
class TestCancelJobTtsProcessing:
"""验证 tts_processing 状态的任务可以被取消."""
def test_cancel_tts_processing(self):
"""tts_processing 状态的任务可以成功取消."""
svc, client, cosy = _make_service_with_mocks()
mock_job = MagicMock()
mock_job.status = "tts_processing"
mock_job.id = "job-1"
svc.get_job = MagicMock(return_value=mock_job)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_pending_still_works(self):
"""pending 状态仍可取消."""
svc, client, cosy = _make_service_with_mocks()
mock_job = MagicMock()
mock_job.status = "pending"
mock_job.id = "job-1"
svc.get_job = MagicMock(return_value=mock_job)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_submitted_still_works(self):
"""submitted 状态仍可取消."""
svc, client, cosy = _make_service_with_mocks()
mock_job = MagicMock()
mock_job.status = "submitted"
mock_job.id = "job-1"
svc.get_job = MagicMock(return_value=mock_job)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
+389
View File
@@ -0,0 +1,389 @@
"""AI 数字人口型 TTS Celery 异步任务 — 单元测试.
覆盖 lipsync_tts.py 的全部主要分支:
- Job 不存在/cancelled/正常/异常路径
- TTS 合成、音频下载、OSS 上传、MediaKit 提交
- CosyVoiceError/ValueError/MediaKitError/顶层异常等错误码
"""
import os
import sys
import types
from types import ModuleType
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
class _FakeQuery:
"""模拟 SQLAlchemy query.filter().first() 链式调用."""
def __init__(self, job):
self._job = job
def filter(self, *args, **kwargs):
return self
def first(self):
return self._job
def _make_fake_job(**kwargs):
"""构造可 setattr 的 job 记录."""
job = MagicMock()
job.id = kwargs.get("job_id", "job-1")
job.user_id = kwargs.get("user_id", "user-1")
job.status = kwargs.get("status", "tts_processing")
job.audio_url = kwargs.get("audio_url", "")
job.video_url = kwargs.get("video_url", "https://oss/video.mp4")
job.mediakit_task_id = kwargs.get("mediakit_task_id", "")
job.enable_video_loop = kwargs.get("enable_video_loop", False)
job.error_code = ""
job.error_message = ""
job.submitted_at = None
job.updated_at = None
return job
def _build_session(job):
"""构造 mock DB session + factory. 返回 (session, factory)."""
session = MagicMock()
session.query.return_value = _FakeQuery(job)
session.commit = MagicMock()
session.close = MagicMock()
factory = MagicMock(return_value=session)
return session, factory
def _apply_all_patches(
*,
job=None,
cosyvoice_service=None,
cosyvoice_side_effect=None,
cosyvoice_error=None,
download_bytes=b"AUDIO",
download_error=None,
storage=None,
mk_client=None,
mk_submit_return=None,
mk_submit_error=None,
):
"""统一构造测试需要的 patch 列表.
lipsync_tts.run() 在函数体内部懒 import 多个模块,通过 sys.modules 注入
伪造包路径避免真实导入;对存在的模块用 patch() 替换返回值/side_effect。
"""
# SessionLocal 通过懒探测获取(Worker 用 worker_app.dbAPI 用 app.db),
# 测试环境里两个模块都能被真实导入,必须同时 mock 保证用的是 fake session。
fake_app_db = ModuleType("app.db")
fake_worker_db = ModuleType("worker_app.db")
session, factory = _build_session(job)
fake_app_db.SessionLocal = factory
fake_worker_db.SessionLocal = factory
patches = [
patch.dict(sys.modules, {"app.db": fake_app_db, "worker_app.db": fake_worker_db}),
patch(
"app.tasks.lipsync_tts._sign_media_url",
side_effect=lambda url: url + "?signed" if url else url,
),
]
# CosyVoice
if cosyvoice_service is not None:
cosy_instance = cosyvoice_service
else:
cosy_instance = MagicMock()
if cosyvoice_side_effect is not None:
cosy_instance.submit_synthesize_task.side_effect = cosyvoice_side_effect
elif cosyvoice_error is not None:
cosy_instance.submit_synthesize_task.side_effect = cosyvoice_error
else:
cosy_instance.submit_synthesize_task.return_value = {"audio_url": "https://tts/raw.mp3"}
patches.append(patch("packages.application.cosyvoice_service.CosyVoiceService", return_value=cosy_instance))
# safe_download_bytes
if download_error is not None:
patches.append(patch("packages.shared.url_security.safe_download_bytes", side_effect=download_error))
else:
patches.append(patch("packages.shared.url_security.safe_download_bytes", return_value=download_bytes))
# Storage
if storage is None:
storage = MagicMock()
storage.public_url = "https://oss.example.com"
storage.upload_file.return_value = "https://oss.example.com/tts.mp3"
patches.append(patch("packages.shared.storage.get_shared_storage_service", return_value=storage))
# MediaKit client
if mk_client is not None:
patches.append(patch("app.services.mediakit_client.get_mediakit_client", return_value=mk_client))
else:
client = MagicMock()
if mk_submit_error is not None:
client.submit_lipsync.side_effect = mk_submit_error
else:
client.submit_lipsync.return_value = mk_submit_return or {"task_id": "mk-1"}
patches.append(patch("app.services.mediakit_client.get_mediakit_client", return_value=client))
return session, patches
class TestTtsSynthesizeAndSubmit:
"""测试 Celery 任务 tts_synthesize_and_submit.run 的所有分支."""
def test_job_not_found_returns_early(self):
"""Job 不存在 → 日志报错直接返回,不抛异常."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
session, patches = _apply_all_patches(job=None)
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("missing-job", "user-1", "v1", "你好", 1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
session.commit.assert_not_called()
session.close.assert_called_once()
def test_cancelled_job_skipped(self):
"""Job 已 cancelled → 跳过不处理,不调用 TTS/MediaKit."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job(status="cancelled")
session, patches = _apply_all_patches(job=job)
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
# cancelled 不应 commit,不应触发 TTS/MediaKit
session.commit.assert_not_called()
session.close.assert_called_once()
def test_happy_path_tts_to_mediakit(self):
"""完整正常流程:TTS 合成 → OSS 上传 → 签名 → 提交 MediaKit → submitted."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job()
mk_client = MagicMock()
mk_client.submit_lipsync.return_value = {"task_id": "mk-999"}
session, patches = _apply_all_patches(job=job, mk_client=mk_client)
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好世界", 1.0, "happy")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-999"
assert job.error_code == ""
mk_client.submit_lipsync.assert_called_once()
call_kwargs = mk_client.submit_lipsync.call_args.kwargs
assert call_kwargs["client_token"] == "job-1"
assert call_kwargs["audio_url"].endswith("?signed")
session.commit.assert_called()
session.close.assert_called_once()
def test_cosyvoice_error_marks_tts_synthesis_failed(self):
"""CosyVoiceError → failed, error_code=TTSSynthesisFailed."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
from packages.application.cosyvoice_service import CosyVoiceError
job = _make_fake_job()
session, patches = _apply_all_patches(job=job, cosyvoice_error=CosyVoiceError("tts boom"))
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
assert job.status == "failed"
assert job.error_code == "TTSSynthesisFailed"
session.close.assert_called_once()
def test_value_error_marks_tts_invalid_param(self):
"""ValueError(参数错误)→ failed, error_code=TTSInvalidParam."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job()
session, patches = _apply_all_patches(job=job, cosyvoice_side_effect=ValueError("bad param"))
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", -1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
assert job.status == "failed"
assert job.error_code == "TTSInvalidParam"
session.close.assert_called_once()
def test_no_audio_url_marks_tts_no_audio(self):
"""TTS 返回空 audio_url → failed, error_code=TTSNoAudio."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job()
cosy = MagicMock()
cosy.submit_synthesize_task.return_value = {"audio_url": ""}
session, patches = _apply_all_patches(job=job, cosyvoice_service=cosy)
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
assert job.status == "failed"
assert job.error_code == "TTSNoAudio"
session.close.assert_called_once()
def test_oss_upload_failure_falls_back_to_temp_url(self):
"""OSS 上传失败 → 回退临时 URL,仍然 submitted."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job()
storage = MagicMock()
storage.public_url = "https://oss.example.com"
storage.upload_file.side_effect = RuntimeError("oss down")
mk_client = MagicMock()
mk_client.submit_lipsync.return_value = {"task_id": "mk-7"}
session, patches = _apply_all_patches(job=job, storage=storage, mk_client=mk_client)
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
# 上传失败后 audio_url 回退为临时 TTS URL,仍继续提交到 MediaKit
assert job.audio_url == "https://tts/raw.mp3"
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-7"
mk_client.submit_lipsync.assert_called_once()
session.close.assert_called_once()
def test_mediakit_error_marks_mediakit_unavailable(self):
"""MediaKit 提交失败 → failed, error_code=MediaKitUnavailable."""
from app.services.mediakit_client import MediaKitError
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job()
mk_err = MediaKitError("mk down", code="MediaKitUnavailable")
session, patches = _apply_all_patches(job=job, mk_submit_error=mk_err)
entered = [p.__enter__() for p in patches]
try:
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
assert job.status == "failed"
assert job.error_code == "MediaKitUnavailable"
session.close.assert_called_once()
def test_top_level_exception_marks_async_task_error(self):
"""顶层未预期异常 → failed, error_code=AsyncTaskError."""
from app.tasks.lipsync_tts import tts_synthesize_and_submit
job = _make_fake_job()
fake_app_db = ModuleType("app.db")
fake_worker_db = ModuleType("worker_app.db")
session, factory = _build_session(job)
fake_app_db.SessionLocal = factory
fake_worker_db.SessionLocal = factory
# CosyVoiceService 在 __init__ 抛 RuntimeError(非 CosyVoiceError/ValueError
fake_cosy_mod = ModuleType("packages.application.cosyvoice_service")
class _CosyVoiceErrorForTest(Exception):
pass
class _BoomService:
def __init__(self):
raise RuntimeError("top-level boom")
fake_cosy_mod.CosyVoiceError = _CosyVoiceErrorForTest
fake_cosy_mod.CosyVoiceService = _BoomService
with patch.dict(
sys.modules,
{
"app.db": fake_app_db,
"worker_app.db": fake_worker_db,
"packages.application.cosyvoice_service": fake_cosy_mod,
},
):
tts_synthesize_and_submit.run("job-1", "user-1", "v1", "你好", 1.0, "")
assert job.status == "failed"
assert job.error_code == "AsyncTaskError"
session.close.assert_called()
class TestSignMediaUrl:
"""覆盖模块内 _sign_media_url 的所有分支(CI 增量覆盖率需要)."""
def test_empty_url_returns_empty(self):
from app.tasks.lipsync_tts import _sign_media_url
assert _sign_media_url("") == ""
assert _sign_media_url(None) is None
def test_own_oss_url_signed(self):
"""自家 OSS URL → 调用 storage.get_download_url 签名."""
from app.tasks.lipsync_tts import _sign_media_url
fake_storage = MagicMock()
fake_storage.public_url = "https://oss.example.com/"
fake_storage.get_download_url.return_value = "https://oss.example.com/a?sig=xyz"
with patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage):
result = _sign_media_url("https://oss.example.com/lipsync/a.mp3")
assert result == "https://oss.example.com/a?sig=xyz"
fake_storage.get_download_url.assert_called_once()
def test_external_url_passthrough(self):
"""外部 URL(不是自家 OSS host)→ 原样透传,不签名."""
from app.tasks.lipsync_tts import _sign_media_url
fake_storage = MagicMock()
fake_storage.public_url = "https://oss.example.com/"
with patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage):
result = _sign_media_url("https://tts.example.com/raw.mp3")
assert result == "https://tts.example.com/raw.mp3"
fake_storage.get_download_url.assert_not_called()
def test_storage_exception_falls_back(self):
"""storage 调用异常 → 降级原样返回,不抛错."""
from app.tasks.lipsync_tts import _sign_media_url
with patch(
"packages.shared.storage.get_shared_storage_service",
side_effect=RuntimeError("storage down"),
):
result = _sign_media_url("https://oss.example.com/a.mp3")
assert result == "https://oss.example.com/a.mp3"
def test_no_public_url_passthrough(self):
"""storage.public_url 为空 → 原样透传."""
from app.tasks.lipsync_tts import _sign_media_url
fake_storage = MagicMock()
fake_storage.public_url = ""
with patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage):
result = _sign_media_url("https://anything.example.com/a.mp3")
assert result == "https://anything.example.com/a.mp3"
fake_storage.get_download_url.assert_not_called()