Files
xiaoxia-saas/tests/unit/test_lipsync_speed_optimization.py
T
CI Bot 1a4f475fbf
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m4s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m32s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 88h49m41s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 89h12m30s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 89h12m43s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 88h49m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 89h12m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 89h12m14s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 89h12m15s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 89h12m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 89h12m21s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 88h49m23s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 89h12m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 88h49m23s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 88h49m19s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 89h12m8s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 89h12m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 89h47m32s
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-12 14:10:26 +00:00

335 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 数字人口型视频生成速度优化 — 单元测试.
验证两个优化点:
1. FFmpeg 编码 preset 从 fast 改为 veryfast(提速 30~50%)
2. TTS 合成从同步改为 Celery 异步任务(API 响应从 6~35s 降到 <1s)
Issue: lipsync-speed-optimization
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
# ═══════════════════════════════════════════════════════════════════════════════
# 优化1: FFmpeg 编码提速 — preset veryfast
# ═══════════════════════════════════════════════════════════════════════════════
class TestFFmpegPresetOptimization:
"""验证 FFmpeg 编码命令从 -preset fast 改为 -preset veryfast."""
def test_preset_is_veryfast(self):
"""_build_ffmpeg_command 输出必须包含 -preset veryfast."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
cmd = svc._build_ffmpeg_command(
input_video="https://example.com/video.mp4",
b_roll_segments=[],
filter_complex="",
final_label=None,
output_path="/tmp/output.mp4",
)
# cmd 现在是 list[str];preset 与值是相邻两个元素
assert "-preset" in cmd, f"期望包含 -preset,实际命令: {cmd}"
preset_idx = cmd.index("-preset")
assert cmd[preset_idx + 1] == "veryfast", f"期望 veryfast,实际: {cmd}"
def test_preset_veryfast_with_filter(self):
"""带滤镜场景下也必须使用 veryfast."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
cmd = svc._build_ffmpeg_command(
input_video="https://example.com/video.mp4",
b_roll_segments=[],
filter_complex="overlay=0:0",
final_label="[v]",
output_path="/tmp/output.mp4",
)
assert "-preset" in cmd
assert cmd[cmd.index("-preset") + 1] == "veryfast"
assert "-filter_complex" in cmd
def test_preset_not_fast(self):
"""确保不再使用旧的 -preset fast."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService.__new__(AiAvatarRenderService)
cmd = svc._build_ffmpeg_command(
input_video="https://example.com/video.mp4",
b_roll_segments=[],
filter_complex="",
final_label=None,
output_path="/tmp/output.mp4",
)
# 确保是 veryfast 而不是 fast
assert "-preset" in cmd
preset_idx = cmd.index("-preset")
assert cmd[preset_idx + 1] == "veryfast"
# 禁止 fast 单独作为 preset 值(veryfast 包含 "fast" 子串,不影响)
assert cmd[preset_idx + 1] != "fast"
# ═══════════════════════════════════════════════════════════════════════════════
# 优化2: TTS 合成 Celery 异步化
# ═══════════════════════════════════════════════════════════════════════════════
def _make_service_with_mocks():
"""构造 LipsyncService 测试实例及 mock 依赖."""
from app.services.lipsync_service import LipsyncService
db = MagicMock()
client = MagicMock()
client.is_available = True
client.submit_lipsync.return_value = {
"success": True,
"task_id": "mk-1",
"request_id": "req-1",
}
cosy = MagicMock()
cosy.submit_synthesize_task.return_value = {
"audio_url": "https://tts/raw.mp3",
"request_id": "tts-req",
"audio_duration": 3.0,
}
svc = LipsyncService(db, client=client, cosyvoice_service=cosy, voice_clone_repo=MagicMock())
# _resolve_voice_id 默认原样返回(repo.get 返回 None)
svc._voice_clone_repo.get.return_value = None
return svc, client, cosy
class TestCreateJobAsyncTTS:
"""验证 TTS 模式改为 Celery 异步后的行为."""
def test_tts_mode_returns_tts_processing_status(self):
"""TTS 模式下 create_job 立即返回,状态为 tts_processing."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="longxiaochun_v3",
script_text="大家好",
speed=1.0,
emotion="",
)
assert job.status == "tts_processing"
def test_tts_mode_dispatches_celery_task(self):
"""TTS 模式必须 dispatch Celery 异步任务."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock()
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试文本",
)
mock_task.apply_async.assert_called_once()
call_kwargs = mock_task.apply_async.call_args
args = call_kwargs.kwargs.get("args") or call_kwargs[1].get("args", call_kwargs[0][0] if call_kwargs[0] else ())
assert args[1] == "user-1" # user_id
assert args[2] == "v-1" # voice_id
assert args[3] == "测试文本" # script_text
def test_tts_mode_celery_dispatch_failure_marks_job_failed(self):
"""Celery dispatch 失败时,job 标为 failed 并写入 error_message,前端轮询能直接看到错误."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock(side_effect=Exception("Celery broker down"))
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试文本",
)
# job 已创建且状态标为 failed
assert job is not None
assert job.status == "failed"
assert "Celery 任务投递失败" in job.error_message
assert job.error_code == "AsyncDispatchFailed"
# MediaKit 未被调用
client.submit_lipsync.assert_not_called()
def test_tts_mode_voice_validation_still_sync(self):
"""TTS 模式下音色校验仍在 HTTP 请求中同步执行."""
from app.services.mediakit_client import MediaKitError
svc, client, cosy = _make_service_with_mocks()
# 模拟音色属于其他用户
other_profile = MagicMock()
other_profile.user_id = "user-other"
svc._voice_clone_repo.get.return_value = other_profile
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit"):
with pytest.raises(MediaKitError) as exc:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="clone-profile-id",
script_text="测试",
)
assert exc.value.code == "VoiceForbidden"
def test_tts_mode_missing_input_raises_immediately(self):
"""缺少 voice_id 或 script_text 时立即报错,不 dispatch Celery 任务."""
from app.services.mediakit_client import MediaKitError
svc, client, cosy = _make_service_with_mocks()
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit") as mock_task:
mock_task.delay = MagicMock()
with pytest.raises(MediaKitError) as exc:
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
# 缺少 voice_id 和 script_text
)
assert exc.value.code == "InvalidInput"
# Celery 任务未被 dispatch
mock_task.delay.assert_not_called()
# TTS 和 MediaKit 均未调用
cosy.submit_synthesize_task.assert_not_called()
client.submit_lipsync.assert_not_called()
class TestCreateJobDirectAudio:
"""验证直接音频模式不受异步化影响."""
def test_direct_audio_still_submits_synchronously(self):
"""直接音频模式仍然同步提交 MediaKit,状态为 submitted."""
svc, client, cosy = _make_service_with_mocks()
with patch("app.tasks.lipsync_tts.tts_synthesize_and_submit") as mock_task:
mock_task.delay = MagicMock()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-1"
client.submit_lipsync.assert_called_once()
# TTS Celery 任务不应被调用
mock_task.delay.assert_not_called()
def test_direct_audio_skips_tts(self):
"""直接音频模式不调用 CosyVoice TTS."""
svc, client, cosy = _make_service_with_mocks()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
cosy.submit_synthesize_task.assert_not_called()
call_kwargs = client.submit_lipsync.call_args
assert call_kwargs.kwargs["audio_url"] == "https://example.com/audio.mp3"
class TestCancelJobTtsProcessing:
"""验证 tts_processing 状态的任务可以被取消."""
def test_cancel_tts_processing(self):
"""tts_processing 状态的任务可以成功取消."""
svc, client, cosy = _make_service_with_mocks()
mock_job = MagicMock()
mock_job.status = "tts_processing"
mock_job.id = "job-1"
svc.get_job = MagicMock(return_value=mock_job)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_pending_still_works(self):
"""pending 状态仍可取消."""
svc, client, cosy = _make_service_with_mocks()
mock_job = MagicMock()
mock_job.status = "pending"
mock_job.id = "job-1"
svc.get_job = MagicMock(return_value=mock_job)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_submitted_still_works(self):
"""submitted 状态仍可取消."""
svc, client, cosy = _make_service_with_mocks()
mock_job = MagicMock()
mock_job.status = "submitted"
mock_job.id = "job-1"
svc.get_job = MagicMock(return_value=mock_job)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
class TestCreateJobCommitOrder:
"""验证事务顺序修复:create_job 必须先 commit 再发 Celery 任务,避免 worker 消费时 job 不可见。"""
def test_commit_called_before_apply_async_in_tts_mode(self):
"""TTS 模式:db.commit() 必须在 apply_async() 之前调用,防止 worker 查不到 job 永远卡在 tts_processing。"""
svc, client, cosy = _make_service_with_mocks()
call_order: list[str] = []
def track_commit():
call_order.append("commit")
def track_apply_async(*args, **kwargs):
call_order.append("apply_async")
svc.db.commit.side_effect = track_commit
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock(side_effect=track_apply_async)
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试",
)
# 至少有一次 commit 在 apply_async 之前
assert "commit" in call_order, "db.commit 必须被调用"
assert "apply_async" in call_order, "apply_async 必须被调用"
assert call_order.index("commit") < call_order.index(
"apply_async"
), f"事务顺序错误:commit 必须在 apply_async 之前,实际顺序 {call_order}"
def test_job_not_found_retry_mechanism_exists(self):
"""worker 侧 job not found 必须有重试机制(self.retry),而不是静默 return。"""
import inspect
from app.tasks.lipsync_tts import tts_synthesize_and_submit
source = inspect.getsource(tts_synthesize_and_submit.run)
assert (
"self.retry" in source or "retry" in source
), "tts_synthesize_and_submit 在 job not found 时必须重试,防止静默失败"