Compare commits

...

35 Commits

Author SHA1 Message Date
xiaoxia a9f596fca3 feat(tts): style语气风格参数全链路接入 + 语速/音量/音调透传修复 (#2005)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 5m58s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8m10s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m32s
CI/CD Pipeline / Validate - Style (push) Successful in 36m8s
CI/CD Pipeline / Unit Tests (push) Successful in 41m59s
CI/CD Pipeline / Integration Tests (push) Successful in 42m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 46m4s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 49m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 7m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 20m52s
CI/CD Pipeline / Validate - Security (push) Successful in 2h17m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-21 04:23:07 +08:00
xiaoxia 9ba036abb3 feat(#2001): 爆款标题样式配置面板后端支持 (#2004)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 9s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 14s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Style (push) Successful in 5m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m1s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Validate - Security (push) Successful in 10m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m40s
CI/CD Pipeline / Unit Tests (push) Successful in 12m39s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
API Base Image Build / Build API Base Image (push) Successful in 31m37s
Worker Base Image Build / Build Worker Base Image (push) Successful in 38m53s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-21 03:01:24 +08:00
saas-backend-agent 3424e55a32 feat(gpu): #1978 MuseTalk v1.5 + GFPGAN face enhancement + ffmpeg pipe optimization (#2003)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 9m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 7m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Integration Tests (push) Successful in 41m58s
CI/CD Pipeline / Validate - Style (push) Successful in 42m28s
CI/CD Pipeline / Unit Tests (push) Failing after 50m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 55m52s
CI/CD Pipeline / Validate - Security (push) Successful in 1h27m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- MuseTalk v1.5 完整推理pipeline(DWPose+SFD GPU人脸检测)
- GFPGANv1.4 人脸超分增强(FP16, 可通过 MUSE_USE_GFPGAN=0 关闭)
- ffmpeg rawvideo pipe 编码替代PNG磁盘IO,省~2s
- 异步推理架构 + /cancel 端点 + 线程锁并发控制
- 长音频驱动架构,禁止ffmpeg预循环视频(避免16x性能退化)

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

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

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

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

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

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

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

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