Files
xiaoxia-saas/tests/unit/test_lipsync_gpu_integration.py
T
xiaoxia 08de0d9946
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 / Check push changed paths (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 Web Image (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
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 / Unit Tests (pull_request) Failing after 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m16s
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 / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) 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
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
perf: async GPU lipsync inference + fix 16x performance regression
- Move GPU wait_for_result to Celery background task (lipsync_gpu_process_async)
  POST /lipsync/jobs now returns <1s instead of blocking 200s+
- Rewrite musetalk_server.py: MuseTalk receives full audio directly
  (v2 architecture) — no pre-looping video before inference
  Output video length = audio length, mux is fast stream copy
- Frontend polls GET /lipsync/jobs/{id} for status updates
- refresh_job_status: GPU async path (processing + no mediakit_task_id)
  skips MediaKit polling; stale jobs (>30min) auto-marked failed
- 21 unit tests pass (11 GPU integration + 10 musetalk audio mux)

Co-Authored-By: Coze <coze-opensource@bytedance.com>
2026-09-20 09:43:47 +08:00

241 lines
11 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_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 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