merge: 合并 develop 解决 AI数字人冲突(v3新契约 + develop增强 双保留)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
前端7文件(基于 v3 契约为主干融合 develop 增强,#1828口径):
- 对口型模式A: video_url+voice_id+script_text+speed+emotion(英文枚举,禁传video_asset_id)
- title_config 按 build_title_drawtext_filter 真实字段映射(text/font_size/font_color/position默认bottom/enabled/bold/stroke/shadow), 不传 titles[]/fontSize/frame
- 智能封面 POST /ai-avatar/render/smart-cover; 语速情绪全链路透传
- 保留 develop: MediaKit中间状态/非阻塞轮询(running中间态)/标题TextArea实时预览+标题库(TitleLibraryModal)
后端4文件取 backend/ai-avatar-merge-1827(0e994072) 已解版本(双模式/TTS直生/emotion透传/中间状态)
tsc ai-avatar 零错误, prettier 全合规
This commit is contained in:
@@ -20,6 +20,7 @@ on:
|
||||
default: "手动触发 - CI漏触发补跑"
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -88,9 +89,22 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
# 优先用 git diff 判断 PR 改动范围(比 API 稳定)
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
if command -v git >/dev/null 2>&1 && [ -d .git ]; then
|
||||
FILES=$(git diff --name-only origin/develop...HEAD 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${FILES:-}" ]; then
|
||||
# fallback 到 API
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${FILES:-}" ]; then
|
||||
echo "⚠️ 无法获取变更文件列表,保守运行完整 CI"
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
|
||||
|
||||
接口:
|
||||
POST /api/v1/lipsync/jobs 提交对口型任务
|
||||
@@ -13,13 +13,19 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_voice_clone_profile_repository
|
||||
from app.dependencies import (
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -28,8 +34,15 @@ router = APIRouter()
|
||||
def _get_service(
|
||||
db: Session = Depends(get_db_session),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> LipsyncService:
|
||||
return LipsyncService(db, voice_clone_repo=voice_clone_repo)
|
||||
# voice_clone_repo 用于克隆音色 profile 解析;cosyvoice_service 用于 TTS 直生
|
||||
# (TTS 合成、音色解析、错误码归一化都在 LipsyncService 内部完成)
|
||||
return LipsyncService(
|
||||
db,
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
voice_clone_repo=voice_clone_repo,
|
||||
)
|
||||
|
||||
|
||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||
@@ -43,7 +56,9 @@ def create_lipsync_job(
|
||||
):
|
||||
"""提交对口型任务.
|
||||
|
||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
||||
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?},
|
||||
后端内部解析音色、调 TTS 合成音频、转存 OSS,再提交 MediaKit;
|
||||
也支持直接传 {video_url, audio_url}。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
@@ -57,6 +72,15 @@ def create_lipsync_job(
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# 参数无效(如 voice_id 格式不对、文本过长等)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except CosyVoiceError as exc:
|
||||
# TTS 合成基础设施失败(API/网络/认证)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"code": "TTSSynthesisFailed", "message": str(exc)},
|
||||
) from exc
|
||||
except MediaKitError as exc:
|
||||
# TTS 合成失败 / 音色无权访问 → 400/403;MediaKit 提交失败 → 502
|
||||
status_code = 502
|
||||
@@ -74,6 +98,13 @@ def create_lipsync_job(
|
||||
"request_id": getattr(exc, "request_id", ""),
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# 兜底:任何未预期的错误返回 400 而非 500
|
||||
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"创建对口型任务失败: {exc}",
|
||||
) from exc
|
||||
|
||||
return job
|
||||
|
||||
@@ -112,13 +143,22 @@ def list_lipsync_jobs(
|
||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||
def get_lipsync_job(
|
||||
job_id: str,
|
||||
background: BackgroundTasks,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情."""
|
||||
"""获取对口型任务详情.
|
||||
|
||||
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
|
||||
避免 MediaKit 慢响应阻塞前端轮询。
|
||||
"""
|
||||
job = svc.get_job(job_id, current_user.user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if job.status not in ("completed", "failed"):
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""对口型 API Schema 定义 — #1796 / #1822.
|
||||
"""对口型 API Schema 定义 — #1796 / #1809 / #1822.
|
||||
|
||||
支持两种输入模式(二选一):
|
||||
1. TTS 直生模式(推荐):传 voice_id + script_text(+ speed/emotion),
|
||||
@@ -45,9 +45,8 @@ class LipsyncJobResponse(BaseModel):
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求.
|
||||
|
||||
两种模式:
|
||||
- TTS 直生:voice_id + script_text 必填;video_url 必填(人物视频);
|
||||
audio_url 留空(后端合成)。
|
||||
两种模式(二选一):
|
||||
- TTS 直生:voice_id + script_text 必填(+ 可选 speed/emotion);audio_url 留空。
|
||||
- 直接音频:video_url + audio_url 必填。
|
||||
"""
|
||||
|
||||
@@ -58,7 +57,7 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
|
||||
# 模式 1:TTS 直生
|
||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
|
||||
|
||||
@@ -85,6 +84,9 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
||||
)
|
||||
|
||||
if has_tts and len(self.script_text) > 5000:
|
||||
raise ValueError("script_text 最长 5000 字符")
|
||||
|
||||
if has_audio:
|
||||
au = self.audio_url.strip()
|
||||
if not au.startswith(("http://", "https://")):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消对口型任务
|
||||
- 创建/查询对口型任务
|
||||
- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url)
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态
|
||||
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
"""
|
||||
|
||||
@@ -193,7 +194,7 @@ class LipsyncService:
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# 2. 提交到 MediaKit
|
||||
# 3. 提交到 MediaKit
|
||||
try:
|
||||
result = self.client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
|
||||
@@ -128,9 +128,9 @@ services:
|
||||
- xiaoxia-net
|
||||
|
||||
# 健康检查配置
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1"]
|
||||
test: ["CMD-SHELL", "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -155,7 +155,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -116,7 +116,7 @@ docker run -d \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -524,6 +524,10 @@ class CosyVoiceService:
|
||||
if not audio_url:
|
||||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
||||
|
||||
# DashScope 返回 http://,统一升级为 https://
|
||||
if audio_url.startswith("http://"):
|
||||
audio_url = audio_url.replace("http://", "https://", 1)
|
||||
|
||||
return {
|
||||
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
||||
"audio_url": audio_url,
|
||||
|
||||
@@ -192,7 +192,7 @@ rollback() {
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
@@ -501,7 +501,7 @@ docker run -d \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""对口型 API 路由 + Service 单元测试 — #1796.
|
||||
"""对口型 API 路由 + Service 单元测试 — #1796, #1809 参数调整.
|
||||
|
||||
CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py
|
||||
"""
|
||||
@@ -33,6 +33,19 @@ def mock_mediakit():
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cosyvoice():
|
||||
"""Mock CosyVoice 服务."""
|
||||
service = MagicMock()
|
||||
service.synthesize_speech.return_value = MagicMock(
|
||||
audio_url="https://oss.example.com/tts-output.mp3",
|
||||
duration=15.0,
|
||||
file_size=12345,
|
||||
request_id="tts-req-789",
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def _make_mock_job(
|
||||
job_id="job-1",
|
||||
user_id="user-1",
|
||||
@@ -48,7 +61,7 @@ def _make_mock_job(
|
||||
m.user_id = user_id
|
||||
m.project_id = ""
|
||||
m.video_url = "https://example.com/video.mp4"
|
||||
m.audio_url = "https://example.com/audio.mp3"
|
||||
m.audio_url = "https://oss.example.com/tts-output.mp3"
|
||||
m.enable_video_loop = False
|
||||
m.mediakit_task_id = mediakit_task_id
|
||||
m.status = status
|
||||
@@ -64,16 +77,19 @@ def _make_mock_job(
|
||||
|
||||
|
||||
class TestSchemaValidation:
|
||||
"""Schema 验证测试."""
|
||||
"""Schema 验证测试 — #1809 新参数结构."""
|
||||
|
||||
def test_valid_video_url(self):
|
||||
def test_valid_request(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="大家好,欢迎来到直播间",
|
||||
)
|
||||
assert req.video_url == "https://example.com/video.mp4"
|
||||
assert req.voice_id == "longxiaochun_v3"
|
||||
assert req.script_text == "大家好,欢迎来到直播间"
|
||||
|
||||
def test_invalid_video_url_not_mp4(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
@@ -81,7 +97,8 @@ class TestSchemaValidation:
|
||||
with pytest.raises(ValueError, match="MP4"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mov",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_invalid_video_url_empty(self):
|
||||
@@ -90,7 +107,8 @@ class TestSchemaValidation:
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url=" ",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_invalid_video_url_not_http(self):
|
||||
@@ -99,26 +117,38 @@ class TestSchemaValidation:
|
||||
with pytest.raises(ValueError, match="HTTP"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="ftp://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_valid_audio_formats(self):
|
||||
def test_empty_voice_id_rejected(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]:
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url=f"https://example.com/audio{ext}",
|
||||
)
|
||||
assert req.audio_url.endswith(ext)
|
||||
|
||||
def test_invalid_audio_format(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="格式不支持"):
|
||||
with pytest.raises(ValueError, match="voice_id"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.ogg",
|
||||
voice_id=" ",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_empty_script_text_rejected(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="script_text"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="",
|
||||
)
|
||||
|
||||
def test_script_text_too_long(self):
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
with pytest.raises(ValueError, match="5000"):
|
||||
CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="x" * 5001,
|
||||
)
|
||||
|
||||
def test_enable_video_loop_default(self):
|
||||
@@ -126,7 +156,8 @@ class TestSchemaValidation:
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
assert req.enable_video_loop is False
|
||||
|
||||
@@ -136,53 +167,111 @@ class TestSchemaValidation:
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4?token=abc",
|
||||
audio_url="https://example.com/audio.mp3?sign=xyz",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
assert "?token=" in req.video_url
|
||||
|
||||
def test_no_audio_url_in_request(self):
|
||||
"""#1809: 请求体不应包含 audio_url 字段."""
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||
|
||||
req = CreateLipsyncJobRequest(
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
assert not hasattr(req, "audio_url")
|
||||
fields = req.model_fields.keys()
|
||||
assert "audio_url" not in fields
|
||||
assert "voice_id" in fields
|
||||
assert "script_text" in fields
|
||||
|
||||
|
||||
class TestLipsyncServiceUnit:
|
||||
"""Service 层单元测试(纯 mock,不依赖数据库)."""
|
||||
"""Service 层单元测试(纯 mock,不依赖数据库)— #1809 更新."""
|
||||
|
||||
def test_create_job_success(self, mock_mediakit):
|
||||
def test_create_job_success(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
|
||||
# 模拟 db.add + db.flush 不报错
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="大家好,欢迎来到直播间",
|
||||
)
|
||||
|
||||
assert job.status == "submitted"
|
||||
assert job.mediakit_task_id == "mk-task-123"
|
||||
# TTS 应该被调用
|
||||
mock_cosyvoice.synthesize_speech.assert_called_once_with(
|
||||
text="大家好,欢迎来到直播间",
|
||||
voice_id="longxiaochun_v3",
|
||||
)
|
||||
# MediaKit 应该用 TTS 生成的 audio_url
|
||||
mock_mediakit.submit_lipsync.assert_called_once()
|
||||
call_kwargs = mock_mediakit.submit_lipsync.call_args
|
||||
assert call_kwargs.kwargs["audio_url"] == "https://oss.example.com/tts-output.mp3"
|
||||
|
||||
def test_create_job_api_failure(self, mock_mediakit):
|
||||
def test_create_job_tts_failure(self, mock_mediakit):
|
||||
"""TTS 合成失败时,应创建 failed 记录并抛出 CosyVoiceError."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
|
||||
mock_cosyvoice = MagicMock()
|
||||
mock_cosyvoice.synthesize_speech.side_effect = CosyVoiceError("TTS 服务不可用")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
with pytest.raises(CosyVoiceError, match="TTS 服务不可用"):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
# 不应提交到 MediaKit
|
||||
mock_mediakit.submit_lipsync.assert_not_called()
|
||||
# 应该记录了失败状态
|
||||
added_job = mock_db.add.call_args[0][0]
|
||||
assert added_job.status == "failed"
|
||||
assert "TTS" in added_job.error_message
|
||||
|
||||
def test_create_job_api_failure(self, mock_mediakit, mock_cosyvoice):
|
||||
"""MediaKit 提交失败."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
|
||||
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
with pytest.raises(MediaKitError, match="API 调用失败"):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
voice_id="longxiaochun_v3",
|
||||
script_text="测试文本",
|
||||
)
|
||||
|
||||
def test_get_job_delegates_to_db(self, mock_mediakit):
|
||||
def test_get_job_delegates_to_db(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_job = _make_mock_job()
|
||||
@@ -193,13 +282,13 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.get_job("job-1", "user-1")
|
||||
|
||||
assert result is mock_job
|
||||
mock_db.query.assert_called_once()
|
||||
|
||||
def test_get_job_not_found(self, mock_mediakit):
|
||||
def test_get_job_not_found(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
@@ -209,11 +298,11 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.get_job("nonexistent", "user-1")
|
||||
assert result is None
|
||||
|
||||
def test_refresh_job_completed(self, mock_mediakit):
|
||||
def test_refresh_job_completed(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_job = _make_mock_job(status="submitted")
|
||||
@@ -224,14 +313,14 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.refresh_job_status("job-1", "user-1")
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.output_video_url == "https://output.mp4"
|
||||
assert result.output_duration == 30.0
|
||||
|
||||
def test_refresh_job_failed(self, mock_mediakit):
|
||||
def test_refresh_job_failed(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_mediakit.get_task_status.return_value = {
|
||||
@@ -251,13 +340,13 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.refresh_job_status("job-1", "user-1")
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.error_code == "DownloadFailed"
|
||||
|
||||
def test_refresh_job_already_completed(self, mock_mediakit):
|
||||
def test_refresh_job_already_completed(self, mock_mediakit, mock_cosyvoice):
|
||||
"""已完成的任务不轮询."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
@@ -269,14 +358,14 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.refresh_job_status("job-1", "user-1")
|
||||
|
||||
# 不应调用 MediaKit
|
||||
mock_mediakit.get_task_status.assert_not_called()
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_cancel_job_pending(self, mock_mediakit):
|
||||
def test_cancel_job_pending(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_job = _make_mock_job(status="pending")
|
||||
@@ -287,12 +376,12 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.cancel_job("job-1", "user-1")
|
||||
|
||||
assert result.status == "cancelled"
|
||||
|
||||
def test_cancel_job_completed_not_allowed(self, mock_mediakit):
|
||||
def test_cancel_job_completed_not_allowed(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_job = _make_mock_job(status="completed")
|
||||
@@ -303,8 +392,85 @@ class TestLipsyncServiceUnit:
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
result = svc.cancel_job("job-1", "user-1")
|
||||
|
||||
# 已完成不可取消
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_create_job_stores_tts_audio_url(self, mock_mediakit, mock_cosyvoice):
|
||||
"""#1809: 验证 job 的 audio_url 来自 TTS 合成结果."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.flush = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="my-clone-voice",
|
||||
script_text="这是一段测试文本",
|
||||
)
|
||||
|
||||
# job.audio_url 应该是 TTS 返回的 URL
|
||||
assert job.audio_url == "https://oss.example.com/tts-output.mp3"
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""#1809 补充:错误返回 400 而非 500."""
|
||||
|
||||
def test_voice_id_resolve_failure_returns_400(self, mock_mediakit, mock_cosyvoice):
|
||||
"""voice_clone_repo 查询异常时返回 400 而非 500."""
|
||||
from app.api.routes.lipsync import _resolve_voice_id
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.side_effect = Exception("DB connection error")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_resolve_voice_id("bad-voice-id", "user-1", mock_repo)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "voice_id" in str(exc_info.value.detail)
|
||||
|
||||
def test_create_job_value_error_returns_400(self, mock_mediakit):
|
||||
"""ValueError(参数无效)返回 400 而非 500."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_cosyvoice = MagicMock()
|
||||
mock_cosyvoice.synthesize_speech.side_effect = ValueError("voice_id 为空")
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
# Service 层会 catch CosyVoiceError 但 ValueError 会穿透
|
||||
# 路由层 catch ValueError → 400
|
||||
with pytest.raises(ValueError):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="",
|
||||
script_text="test",
|
||||
)
|
||||
|
||||
def test_create_job_unexpected_exception_returns_400(self, mock_mediakit):
|
||||
"""未预期的异常应被路由层捕获返回 400 而非 500."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_cosyvoice = MagicMock()
|
||||
mock_cosyvoice.synthesize_speech.side_effect = RuntimeError("unexpected")
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="test-voice",
|
||||
script_text="test",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user