Compare commits

..

6 Commits

Author SHA1 Message Date
x 4f02d3a04e feat(web): #2001 爆款标题样式配置面板升级 + ai-avatar 配音面板清理emotion
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m7s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker 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 / Frontend Unit Tests (pull_request) Successful in 1m0s
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 5m53s
AI Code Review / AI Code Review (pull_request) Successful in 7m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m12s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 25m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 29m14s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 2h24m14s
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 3s
- 新增描边宽色/阴影参数/行距/上边距/maxCharsPerLine/背景色块/逐行/封面独立配置
- 4款爆款字体 + 抖音爆款黄预设
- 共享 TitleStylePanel + TitleMiniPreview Canvas预览
- 全链路适配(Canvas/contract/useGenerateVideo/useBatchCovers/usePlanConfigLoader)
- CreateGenerationTaskRequest/GenerateCoverTitleConfig 类型扩展
- ai-avatar: 删除情绪下拉框,tts-preview补传style,lipsync去emotion参数
- normalizeEmotion 工具函数保留
2026-09-20 22:48:37 +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
48 changed files with 3336 additions and 382 deletions
+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 试听响应 */
+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
+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}
@@ -41,6 +41,8 @@ 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> => {
@@ -55,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,
+14 -1
View File
@@ -77,6 +77,8 @@ const GeneratePage: React.FC = () => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -329,6 +331,7 @@ const GeneratePage: React.FC = () => {
selectedScript,
ttsVoiceId,
ttsVoiceSource,
ttsStyle,
ttsVoiceAssetId,
dedupEnabled,
style,
@@ -410,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)
@@ -428,6 +437,7 @@ const GeneratePage: React.FC = () => {
[
setTtsVoiceId,
setTtsVoiceSource,
setTtsStyle,
setTtsVoiceAssetId,
setSelectedVoice,
setSelectedClonedVoice,
@@ -630,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}
@@ -791,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,
}
@@ -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(带语言回退)
+3
View File
@@ -48,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 定时任务调度
+1 -1
View File
@@ -3,7 +3,7 @@
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="/tmp/gpu-poll.log"
LOG_FILE="$HOME/gpu-poll.log"
log() {
echo "[$(date +"%Y-%m-%d %H:%M:%S")] $*" >> "$LOG_FILE"
+2 -1
View File
@@ -59,4 +59,5 @@ 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 /tmp/gpu-worker-update.log"
echo "更新日志:tail -f ~/gpu-worker-update.log"
echo "轮询日志:tail -f ~/gpu-poll.log"
@@ -4,7 +4,7 @@ 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="/tmp/gpu-worker-update.log"
LOG_FILE="$HOME/gpu-worker-update.log"
log() {
local NOW
+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
+250
View File
@@ -0,0 +1,250 @@
"""Celery 任务 lipsync_gpu_process_async 直接单测 (#1978 异步化).
覆盖 apps/api/app/tasks/lipsync_gpu.py 的全部主路径:
- 成功:wait_for_result 返回 done → 签名 URL → completed
- GPU 超时/失败 → MediaKit 兜底(成功/MediaKitError/其他异常)
- job 不存在 / 状态异常提前返回
- 主流程异常 → job 标 failed
- _sign_media_url 各分支
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import app.tasks.lipsync_gpu as task_mod
import pytest
def _make_job(status="processing"):
job = MagicMock()
job.id = "job-1"
job.user_id = "u1"
job.status = status
job.video_url = "videos/v.mp4"
job.audio_url = "audios/a.wav"
job.enable_video_loop = True
return job
def _make_gpu_task(status="done", result_url="gpu-lipsync/results/t1.mp4", result_duration=11.2):
t = MagicMock()
t.status = status
t.result_url = result_url
t.result_duration = result_duration
return t
@pytest.fixture()
def db_patch():
"""patch _get_db_session 返回 MagicMock,并在任务结束后断言 close."""
fake_db = MagicMock()
with patch.object(task_mod, "_get_db_session", return_value=fake_db):
yield fake_db
def _patch_gpu_service(final_task):
fake_svc = MagicMock()
fake_svc.wait_for_result.return_value = final_task
return patch(
"app.services.gpu_lipsync_service.GpuLipsyncService",
return_value=fake_svc,
)
def _run_task():
# @shared_task bind=True:直接调用任务对象会自动注入 self
task_mod.lipsync_gpu_process_async("job-1", "u1", "gpu-task-1")
class TestHappyPath:
def test_gpu_done_marks_completed(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task()
storage = MagicMock()
storage.get_download_url.return_value = "https://signed.example.com/r1.mp4?sig=x"
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
):
_run_task()
assert job.status == "completed"
assert job.output_video_url == "https://signed.example.com/r1.mp4?sig=x"
assert job.output_duration == 11.2
assert job.completed_at is not None
db_patch.commit.assert_called_once()
db_patch.close.assert_called_once()
def test_gpu_done_empty_signed_url_keeps_original(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(result_url="gpu/r2.mp4")
storage = MagicMock()
storage.get_download_url.return_value = ""
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
):
_run_task()
assert job.status == "completed"
assert job.output_video_url == "gpu/r2.mp4"
def test_gpu_done_result_duration_none_defaults_zero(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(result_duration=None)
storage = MagicMock()
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
):
_run_task()
assert job.output_duration == 0.0
def test_sign_failure_uses_original_url(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(result_url="gpu/r3.mp4")
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "get_shared_storage_service", side_effect=RuntimeError("oss down")),
):
_run_task()
assert job.status == "completed"
assert job.output_video_url == "gpu/r3.mp4"
class TestJobGuards:
def test_job_not_found_returns(self, db_patch):
db_patch.query.return_value.filter_by.return_value.first.return_value = None
_run_task()
db_patch.commit.assert_not_called()
db_patch.close.assert_called_once()
def test_job_wrong_status_skipped(self, db_patch):
job = _make_job(status="completed")
db_patch.query.return_value.filter_by.return_value.first.return_value = job
_run_task()
db_patch.commit.assert_not_called()
class TestGpuFailureFallback:
def test_gpu_timeout_falls_back_mediakit_success(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
with (
_patch_gpu_service(None),
patch.object(task_mod, "_fallback_to_mediakit") as fb,
):
_run_task()
fb.assert_called_once_with(db_patch, job)
def test_gpu_failed_status_falls_back(self, db_patch):
job = _make_job()
db_patch.query.return_value.filter_by.return_value.first.return_value = job
gpu_task = _make_gpu_task(status="failed")
with (
_patch_gpu_service(gpu_task),
patch.object(task_mod, "_fallback_to_mediakit") as fb,
):
_run_task()
fb.assert_called_once_with(db_patch, job)
class TestFallbackToMediaKit:
def test_mediakit_success_marks_submitted(self, db_patch):
job = _make_job()
fake_client = MagicMock()
fake_client.submit_lipsync.return_value = {"task_id": "mk-99"}
with (
patch("app.services.mediakit_client.get_mediakit_client", return_value=fake_client),
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u + "?s"),
):
task_mod._fallback_to_mediakit(db_patch, job)
fake_client.submit_lipsync.assert_called_once()
kwargs = fake_client.submit_lipsync.call_args.kwargs
assert kwargs["enable_video_loop"] is True
assert kwargs["client_token"] == "job-1"
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-99"
db_patch.commit.assert_called_once()
def test_mediakit_error_marks_failed(self, db_patch):
from app.services.mediakit_client import MediaKitError
job = _make_job()
fake_client = MagicMock()
fake_client.submit_lipsync.side_effect = MediaKitError("api reject", code="MkReject")
with (
patch("app.services.mediakit_client.get_mediakit_client", return_value=fake_client),
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u),
):
task_mod._fallback_to_mediakit(db_patch, job)
assert job.status == "failed"
assert job.error_code == "MkReject"
db_patch.commit.assert_called_once()
def test_other_exception_marks_failed(self, db_patch):
job = _make_job()
with (
patch("app.services.mediakit_client.get_mediakit_client", side_effect=RuntimeError("boom")),
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u),
):
task_mod._fallback_to_mediakit(db_patch, job)
assert job.status == "failed"
assert job.error_code == "FallbackFailed"
db_patch.commit.assert_called_once()
class TestTaskException:
def test_unexpected_exception_marks_job_failed(self, db_patch):
job = _make_job()
# query 第一次返回 job,异常路径里再次 query 也返回 job
db_patch.query.return_value.filter_by.return_value.first.return_value = job
with patch(
"app.services.gpu_lipsync_service.GpuLipsyncService",
side_effect=RuntimeError("svc ctor fail"),
):
_run_task()
assert job.status == "failed"
assert job.error_code == "GpuAsyncError"
def test_exception_handler_failure_swallowed(self, db_patch):
# 主流程异常,且异常处理中的 query 也抛异常 → 不应再抛
db_patch.query.side_effect = RuntimeError("db totally broken")
_run_task()
db_patch.close.assert_called_once()
class TestSignMediaUrl:
def test_empty_url_returned_as_is(self):
assert task_mod._sign_media_url("") == ""
def test_non_own_host_returned_as_is(self):
storage = MagicMock()
storage.public_url = "https://own-bucket.oss-cn-beijing.aliyuncs.com"
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
url = "https://other.example.com/a.wav"
assert task_mod._sign_media_url(url) == url
def test_own_host_signed(self):
storage = MagicMock()
storage.public_url = "https://own-bucket.oss-cn-beijing.aliyuncs.com"
storage.get_download_url.return_value = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a?sig=1"
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
out = task_mod._sign_media_url("https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav")
assert out.endswith("?sig=1")
storage.get_download_url.assert_called_once()
def test_missing_public_url_returns_original(self):
storage = MagicMock()
storage.public_url = ""
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
url = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav"
assert task_mod._sign_media_url(url) == url
def test_exception_returns_original(self):
with patch.object(task_mod, "get_shared_storage_service", side_effect=RuntimeError("x")):
url = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav"
assert task_mod._sign_media_url(url) == url
@@ -141,6 +141,46 @@ class TestGpuFallback:
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_gpu_create_returns_none_falls_back_mediakit(self, fake_db, fake_mediakit):
"""_submit_to_gpu_create 返回 Nonecreate_task 失败被内部吞掉)→ rollback + MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
with (
_patch_storage(),
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
patch.object(svc, "_submit_to_gpu_create", return_value=None) as m_create,
):
job = _make_job()
svc._submit_audio_direct(job=job)
m_create.assert_called_once()
fake_db.rollback.assert_called_once()
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_submit_to_gpu_wait_timeout_returns(self, fake_db, fake_mediakit):
"""降级同步等待:wait_for_result 返回 None → 直接返回,job 保持 processing."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.wait_for_result.return_value = None
job = _make_job()
job.status = "processing"
svc._submit_to_gpu_wait(job=job, gpu_svc=fake_gpu_svc, gpu_task=MagicMock(id="gpu-task-x"))
fake_gpu_svc.wait_for_result.assert_called_once_with("gpu-task-x")
fake_db.commit.assert_not_called()
assert job.status == "processing"
def test_submit_to_gpu_wait_failed_status_returns(self, fake_db, fake_mediakit):
"""降级同步等待:final_task.status != done → 直接返回."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.wait_for_result.return_value = MagicMock(status="failed", result_url="")
job = _make_job()
job.status = "processing"
svc._submit_to_gpu_wait(job=job, gpu_svc=fake_gpu_svc, gpu_task=MagicMock(id="gpu-task-y"))
fake_db.commit.assert_not_called()
assert job.status == "processing"
def test_gpu_external_audio_persisted_to_own_oss(self, fake_db, fake_mediakit):
"""Bug2 回归:dashscope 临时音频 URL 在创建 GPU 任务前转存自家 OSS."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
@@ -212,6 +252,53 @@ class TestGpuFallback:
assert fake_gpu_svc.create_task.call_args.kwargs["audio_url"] == dashscope_url
class TestRefreshGpuStale:
"""refresh_job_status 的 GPU 异步 stale 超时分支."""
def test_stale_gpu_job_marked_failed(self, fake_db):
from datetime import UTC, datetime, timedelta
svc = _make_svc(fake_db, MagicMock(), use_gpu=True)
job = MagicMock()
job.status = "processing"
job.mediakit_task_id = "gpu:gpu-task-stale"
job.updated_at = datetime.now(UTC) - timedelta(minutes=31)
with patch.object(svc, "get_job", return_value=job):
result = svc.refresh_job_status("job-stale", "u1")
assert result is job
assert job.status == "failed"
assert job.error_code == "GpuTimeout"
fake_db.commit.assert_called_once()
def test_fresh_gpu_job_left_processing(self, fake_db):
from datetime import UTC, datetime, timedelta
svc = _make_svc(fake_db, MagicMock(), use_gpu=True)
job = MagicMock()
job.status = "processing"
job.mediakit_task_id = "gpu:gpu-task-fresh"
job.updated_at = datetime.now(UTC) - timedelta(minutes=2)
with patch.object(svc, "get_job", return_value=job):
result = svc.refresh_job_status("job-fresh", "u1")
assert result is job
assert job.status == "processing"
fake_db.commit.assert_not_called()
def test_naive_updated_at_stale_marked_failed(self, fake_db):
"""updated_at 为 naive datetime 时按 UTC 补时区后再判定."""
from datetime import UTC, datetime, timedelta
svc = _make_svc(fake_db, MagicMock(), use_gpu=True)
job = MagicMock()
job.status = "gpu_processing"
job.mediakit_task_id = "gpu:gpu-task-naive"
job.updated_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=31)
with patch.object(svc, "get_job", return_value=job):
svc.refresh_job_status("job-naive", "u1")
assert job.status == "failed"
assert job.error_code == "GpuTimeout"
class TestGpuServiceHelpers:
"""GpuLipsyncService.has_available_worker 测试."""