Compare commits

..

5 Commits

Author SHA1 Message Date
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
29 changed files with 701 additions and 14 deletions
@@ -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 试听响应 */
@@ -0,0 +1,151 @@
/**
* TTS 配音风格选择器
* - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货)
* - 卡片单选,选中高亮紫色
* - 默认 natural
*
* 复用方式:
* <TtsStyleSelector value={style} onChange={setStyle} />
* <TtsStyleSelector value={style} onChange={setStyle} compact /> // 紧凑模式(小尺寸)
*/
import React from "react"
import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
export interface TtsStyleSelectorProps {
value?: TtsStyle | string
onChange: (style: TtsStyle) => void
/** 紧凑模式(小卡片),适合与其他参数并排 */
compact?: boolean
/** 是否显示"配音风格"标签 */
showLabel?: boolean
}
const TtsStyleSelector: React.FC<TtsStyleSelectorProps> = ({
value,
onChange,
compact = false,
showLabel = true,
}) => {
const current = value || DEFAULT_TTS_STYLE
if (compact) {
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 6,
}}
>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 6,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "6px 4px",
borderRadius: 6,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#374151",
cursor: "pointer",
fontSize: 12,
fontWeight: selected ? 600 : 400,
textAlign: "center",
transition: "all 0.15s",
lineHeight: 1.3,
}}
>
<span style={{ marginRight: 3 }}>{opt.emoji}</span>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 8,
fontWeight: 500,
}}
>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 8,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "10px 8px",
borderRadius: 8,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#111",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<span style={{ fontSize: 22, lineHeight: 1 }}>{opt.emoji}</span>
<span style={{ fontSize: 13, fontWeight: selected ? 600 : 500 }}>{opt.label}</span>
<span
style={{
fontSize: 10,
color: "#9ca3af",
lineHeight: 1.2,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{opt.description}
</span>
</button>
)
})}
</div>
</div>
)
}
export default TtsStyleSelector
+13 -2
View File
@@ -94,7 +94,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.emotion, state.style])
const _clearTtsProgressTimer = useCallback(() => {
if (ttsProgressTimerRef.current) {
@@ -175,7 +175,14 @@ 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.emotion,
state.style,
])
const handleRetryTts = useCallback(() => {
handleGenerateTts()
@@ -255,6 +262,7 @@ const AiAvatarPage: React.FC = () => {
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
style: state.style,
}
}
const job = await createLipsyncJob(payload)
@@ -299,6 +307,7 @@ const AiAvatarPage: React.FC = () => {
state.scriptText,
state.speed,
state.emotion,
state.style,
state.ttsPreview,
])
@@ -600,6 +609,8 @@ const AiAvatarPage: React.FC = () => {
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
@@ -7,6 +7,8 @@ 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,
@@ -24,6 +26,8 @@ interface PanelVoiceSelectorProps {
onSelectVoice: (voice: UnifiedVoiceItem) => void
emotion: VoiceEmotion
onEmotionChange: (e: VoiceEmotion) => void
style: TtsStyle
onStyleChange: (s: TtsStyle) => void
speed: number
onSpeedChange: (s: number) => void
language: VoiceLanguage
@@ -37,6 +41,8 @@ export function PanelVoiceSelector({
onSelectVoice,
emotion,
onEmotionChange,
style,
onStyleChange,
speed,
onSpeedChange,
language,
@@ -139,7 +145,8 @@ 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
@@ -153,13 +160,14 @@ export function PanelVoiceSelector({
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)
@@ -324,6 +332,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>
)
@@ -17,6 +17,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,
@@ -35,6 +36,7 @@ export function useAiAvatar() {
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")
@@ -115,6 +117,8 @@ export function useAiAvatar() {
setSelectedVoice,
emotion,
setEmotion,
style,
setStyle,
speed,
setSpeed,
language,
+13 -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,
@@ -791,6 +801,8 @@ const GeneratePage: React.FC = () => {
open={ttsModalOpen}
scriptText={selectedScript?.content ?? ""}
scriptTitle={selectedScript?.title ?? ""}
style={ttsStyle}
onStyleChange={setTtsStyle}
onCancel={() => setTtsModalOpen(false)}
onSynthesized={handleTtsSynthesized}
/>
@@ -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) => {
@@ -22,6 +22,8 @@ export interface UseGenerateVideoProps {
ttsVoiceId?: string
/** TTS 音色来源 */
ttsVoiceSource?: "preset" | "clone"
/** TTS 配音风格 */
ttsStyle?: string
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
ttsVoiceAssetId?: string
/** 智能降重开关(默认 true) */
@@ -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"
@@ -95,6 +96,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 +238,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 +316,8 @@ export const useGenerateFormState = (): GenerateFormState => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -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,
@@ -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 测试."""