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