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
335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""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 时必须重试,防止静默失败"
|