Files
xiaoxia-saas/tests/unit/test_lipsync_gpu_integration.py
T
xiaoxia 9d8c6260e3
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
perf: async GPU lipsync inference + fix 16x performance regression (#1997) (#1998)
2026-09-20 12:19:35 +08:00

326 lines
15 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.
"""LipsyncService GPU 路径集成测试 (#1978 异步版本).
#1978 性能修复:GPU 推理从同步等待改为异步。
- _submit_audio_direct 创建 GPU 任务后立即返回,job.status="processing"
- Celery 任务 lipsync_gpu_process_async 负责等待结果+回写
- 本测试验证:创建任务、异步派发、音频转存等逻辑
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def fake_db():
db = MagicMock()
return db
@pytest.fixture()
def fake_mediakit():
client = MagicMock()
client.submit_lipsync.return_value = {"task_id": "mk-task-1"}
return client
def _make_job(video_url="videos/video.mp4", audio_url="audios/audio.wav"):
job = MagicMock()
job.id = "job-1"
job.user_id = "u1"
job.project_id = "p1"
job.video_url = video_url
job.audio_url = audio_url
job.enable_video_loop = True
job.script_text = ""
job.sentence_timings = None
return job
def _make_svc(db, mediakit, use_gpu=False):
from app.services.lipsync_service import LipsyncService
svc = LipsyncService(db=db, client=mediakit)
svc.settings.use_gpu_lipsync = use_gpu
svc._sign_media_url = lambda u: (u or "") + "?signed"
return svc
def _patch_storage(public_url="https://own-bucket.oss-cn-beijing.aliyuncs.com", signed_suffix="?signed-7d"):
"""patch get_shared_storage_service,返回自家 OSS storage mock."""
storage = MagicMock()
storage.public_url = public_url
storage.get_download_url.side_effect = lambda key_or_url, expires_seconds=3600: key_or_url + signed_suffix
return patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage)
class TestGpuFallback:
def test_switch_off_uses_mediakit(self, fake_db, fake_mediakit):
"""开关关闭时直接走 MediaKit,不创建 GPU 任务."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
job = _make_job()
with patch.object(svc, "_submit_to_gpu_create") as m_sub:
svc._submit_audio_direct(job=job)
m_sub.assert_not_called()
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_switch_on_no_worker_falls_back(self, fake_db, fake_mediakit):
"""开关打开但 has_available_worker=False → 回退 MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = False
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
job = _make_job()
svc._submit_audio_direct(job=job)
fake_gpu_svc.create_task.assert_not_called()
fake_mediakit.submit_lipsync.assert_called_once()
assert job.status == "submitted"
def test_gpu_success_dispatches_async(self, fake_db, fake_mediakit):
"""#1978 异步:GPU 任务创建成功 → job.status=processing,Celery 异步派发."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-1")
with (
_patch_storage(),
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
patch("app.services.lipsync_service.lipsync_gpu_process_async") as m_celery,
):
job = _make_job()
svc._submit_audio_direct(job=job)
fake_gpu_svc.create_task.assert_called_once()
fake_mediakit.submit_lipsync.assert_not_called()
# 异步模式:job 立即设为 processing,Celery 任务派发
assert job.status == "processing"
assert job.mediakit_task_id == "gpu:gpu-task-1"
m_celery.apply_async.assert_called_once_with(args=("job-1", "u1", "gpu-task-1"))
def test_gpu_celery_dispatch_failure_falls_back_sync(self, fake_db, fake_mediakit):
"""Celery 派发失败 → 降级同步等待 GPU 结果."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
gpu_done = MagicMock(
id="gpu-task-1",
status="done",
result_url="gpu-lipsync/results/gpu-task-1.mp4",
result_duration=12.5,
)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-1")
fake_gpu_svc.wait_for_result.return_value = gpu_done
with (
_patch_storage() as storage_p,
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
patch("app.services.lipsync_service.lipsync_gpu_process_async") as m_celery,
):
m_celery.apply_async.side_effect = RuntimeError("Celery down")
storage = storage_p()
job = _make_job()
svc._submit_audio_direct(job=job)
# 降级同步等待完成
fake_gpu_svc.wait_for_result.assert_called_once()
assert job.status == "completed"
assert job.output_duration == 12.5
storage.get_download_url.assert_called_once_with(
"gpu-lipsync/results/gpu-task-1.mp4", expires_seconds=7 * 24 * 3600
)
def test_gpu_create_failure_falls_back(self, fake_db, fake_mediakit):
"""GPU 任务创建异常 → 回退 MediaKit."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.side_effect = RuntimeError("DB down")
with _patch_storage(), patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
job = _make_job()
svc._submit_audio_direct(job=job)
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 返回 None(create_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)
dashscope_url = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/tmp/abc.mp3"
job = _make_job(audio_url=dashscope_url)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-2")
with (
_patch_storage() as storage_p,
patch("app.services.lipsync_service.safe_download_bytes", return_value=b"FAKE-MP3") as m_dl,
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
patch("app.services.lipsync_service.lipsync_gpu_process_async"),
):
storage = storage_p()
storage.upload_file.return_value = "https://own-bucket.oss-cn-beijing.aliyuncs.com/lipsync-tts/u1/job-1.mp3"
svc._submit_audio_direct(job=job)
# 外部音频在 GPU 分支被额外下载并转存到约定 key
gpu_dl_calls = [c for c in m_dl.call_args_list if c.kwargs.get("purpose") == "lipsync_gpu_tts_audio"]
assert len(gpu_dl_calls) == 1
assert gpu_dl_calls[0].args[0] == dashscope_url
storage.upload_file.assert_called_once()
args, kwargs = storage.upload_file.call_args
assert args[1] == "lipsync-tts/u1/job-1.mp3"
assert kwargs.get("content_type") == "audio/mpeg"
# 创建 GPU 任务时用的是自家 OSS URL
kwargs_create = fake_gpu_svc.create_task.call_args.kwargs
assert kwargs_create["audio_url"] == "https://own-bucket.oss-cn-beijing.aliyuncs.com/lipsync-tts/u1/job-1.mp3"
assert kwargs_create["audio_url"] != dashscope_url
def test_gpu_own_audio_not_repersisted(self, fake_db, fake_mediakit):
"""Bug2:已是自家 OSS 的音频(含裸 key)不重复下载转存."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
job = _make_job(audio_url="lipsync-tts/u1/job-1.mp3")
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-3")
with (
_patch_storage() as storage_p,
patch("app.services.lipsync_service.safe_download_bytes") as m_dl,
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
patch("app.services.lipsync_service.lipsync_gpu_process_async"),
):
storage = storage_p()
svc._submit_audio_direct(job=job)
# GPU 转存分支不应下载/上传
gpu_dl_calls = [c for c in m_dl.call_args_list if c.kwargs.get("purpose") == "lipsync_gpu_tts_audio"]
assert gpu_dl_calls == []
storage.upload_file.assert_not_called()
assert fake_gpu_svc.create_task.call_args.kwargs["audio_url"] == "lipsync-tts/u1/job-1.mp3"
def test_gpu_external_audio_persist_fail_uses_original_url(self, fake_db, fake_mediakit):
"""Bug2:外部音频转存失败不阻断,用原始 URL 建任务."""
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
dashscope_url = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/tmp/abc.mp3"
job = _make_job(audio_url=dashscope_url)
fake_gpu_svc = MagicMock()
fake_gpu_svc.has_available_worker.return_value = True
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-4")
with (
_patch_storage() as storage_p,
patch("app.services.lipsync_service.safe_download_bytes", side_effect=RuntimeError("network blocked")),
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
patch("app.services.lipsync_service.lipsync_gpu_process_async"),
):
storage = storage_p()
svc._submit_audio_direct(job=job)
storage.upload_file.assert_not_called()
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 测试."""
def test_no_workers(self, fake_db):
from app.services.gpu_lipsync_service import GpuLipsyncService
svc = GpuLipsyncService(db=fake_db)
fake_db.query.return_value.filter.return_value.first.return_value = None
assert svc.has_available_worker() is False
def test_fresh_worker_available(self, fake_db):
from app.services.gpu_lipsync_service import GpuLipsyncService
svc = GpuLipsyncService(db=fake_db)
svc.settings.gpu_worker_stale_seconds = 300
fake_db.query.return_value.filter.return_value.first.return_value = MagicMock()
assert svc.has_available_worker() is True
def test_stale_worker_unavailable(self, fake_db):
from app.services.gpu_lipsync_service import GpuLipsyncService
svc = GpuLipsyncService(db=fake_db)
fake_db.query.return_value.filter.return_value.first.return_value = None
assert svc.has_available_worker() is False