diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index 0dfa3a05b..c0d989dea 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -1,4 +1,4 @@ -"""对口型 API 路由 — #1796 MediaKit 对口型. +"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整. 接口: POST /api/v1/lipsync/jobs 提交对口型任务 @@ -13,13 +13,19 @@ from __future__ import annotations import logging from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_db_session, get_voice_clone_profile_repository +from app.dependencies import ( + get_cosyvoice_service, + get_db_session, + get_voice_clone_profile_repository, +) from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse from app.services.lipsync_service import LipsyncService from app.services.mediakit_client import MediaKitError -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from sqlalchemy.orm import Session +from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService + logger = logging.getLogger(__name__) router = APIRouter() @@ -28,8 +34,15 @@ router = APIRouter() def _get_service( db: Session = Depends(get_db_session), voice_clone_repo=Depends(get_voice_clone_profile_repository), + cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), ) -> LipsyncService: - return LipsyncService(db, voice_clone_repo=voice_clone_repo) + # voice_clone_repo 用于克隆音色 profile 解析;cosyvoice_service 用于 TTS 直生 + # (TTS 合成、音色解析、错误码归一化都在 LipsyncService 内部完成) + return LipsyncService( + db, + cosyvoice_service=cosyvoice_service, + voice_clone_repo=voice_clone_repo, + ) # ── POST /jobs — 提交对口型任务 ─────────────────────────────────────────── @@ -43,7 +56,9 @@ def create_lipsync_job( ): """提交对口型任务. - 输入人物视频 + 驱动音频,异步生成口型对齐视频。 + #1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?}, + 后端内部解析音色、调 TTS 合成音频、转存 OSS,再提交 MediaKit; + 也支持直接传 {video_url, audio_url}。 """ try: job = svc.create_job( @@ -57,6 +72,15 @@ def create_lipsync_job( enable_video_loop=body.enable_video_loop, project_id=body.project_id, ) + except ValueError as exc: + # 参数无效(如 voice_id 格式不对、文本过长等) + raise HTTPException(status_code=400, detail=str(exc)) from exc + except CosyVoiceError as exc: + # TTS 合成基础设施失败(API/网络/认证) + raise HTTPException( + status_code=502, + detail={"code": "TTSSynthesisFailed", "message": str(exc)}, + ) from exc except MediaKitError as exc: # TTS 合成失败 / 音色无权访问 → 400/403;MediaKit 提交失败 → 502 status_code = 502 @@ -74,6 +98,13 @@ def create_lipsync_job( "request_id": getattr(exc, "request_id", ""), }, ) from exc + except Exception as exc: + # 兜底:任何未预期的错误返回 400 而非 500 + logger.error("创建对口型任务异常: %s", exc, exc_info=True) + raise HTTPException( + status_code=400, + detail=f"创建对口型任务失败: {exc}", + ) from exc return job @@ -112,13 +143,22 @@ def list_lipsync_jobs( @router.get("/jobs/{job_id}", response_model=LipsyncJobResponse) def get_lipsync_job( job_id: str, + background: BackgroundTasks, current_user: AuthenticatedUser = Depends(get_current_user), svc: LipsyncService = Depends(_get_service), ): - """获取对口型任务详情.""" + """获取对口型任务详情. + + 非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态), + 避免 MediaKit 慢响应阻塞前端轮询。 + """ job = svc.get_job(job_id, current_user.user.id) if job is None: raise HTTPException(status_code=404, detail="任务不存在") + + if job.status not in ("completed", "failed"): + background.add_task(svc.refresh_job_status, job_id, current_user.user.id) + return job diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py index 0836f3fdc..77fe9fa5b 100644 --- a/apps/api/app/schemas/lipsync.py +++ b/apps/api/app/schemas/lipsync.py @@ -1,4 +1,4 @@ -"""对口型 API Schema 定义 — #1796 / #1822. +"""对口型 API Schema 定义 — #1796 / #1809 / #1822. 支持两种输入模式(二选一): 1. TTS 直生模式(推荐):传 voice_id + script_text(+ speed/emotion), @@ -45,9 +45,8 @@ class LipsyncJobResponse(BaseModel): class CreateLipsyncJobRequest(BaseModel): """创建对口型任务请求. - 两种模式: - - TTS 直生:voice_id + script_text 必填;video_url 必填(人物视频); - audio_url 留空(后端合成)。 + 两种模式(二选一): + - TTS 直生:voice_id + script_text 必填(+ 可选 speed/emotion);audio_url 留空。 - 直接音频:video_url + audio_url 必填。 """ @@ -58,7 +57,7 @@ class CreateLipsyncJobRequest(BaseModel): # 模式 1:TTS 直生 voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)") - script_text: str = Field("", description="要合成的文案(直生模式必填)") + script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)") @@ -85,6 +84,9 @@ class CreateLipsyncJobRequest(BaseModel): "要么同时传 voice_id + script_text(TTS 直生模式)" ) + if has_tts and len(self.script_text) > 5000: + raise ValueError("script_text 最长 5000 字符") + if has_audio: au = self.audio_url.strip() if not au.startswith(("http://", "https://")): diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index f8db385aa..36fa4b20c 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -1,9 +1,10 @@ -"""对口型 Service — #1796 MediaKit 对口型业务逻辑. +"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整. 职责: -- 创建/查询/取消对口型任务 +- 创建/查询对口型任务 +- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url) - 调用 MediaKit 客户端提交异步任务 -- 轮询更新任务状态 +- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS) - 用户隔离(每个用户只能操作自己的任务) """ @@ -193,7 +194,7 @@ class LipsyncService: self.db.add(job) self.db.flush() - # 2. 提交到 MediaKit + # 3. 提交到 MediaKit try: result = self.client.submit_lipsync( video_url=video_url, diff --git a/packages/application/cosyvoice_service.py b/packages/application/cosyvoice_service.py index d47c83570..acf6d3755 100755 --- a/packages/application/cosyvoice_service.py +++ b/packages/application/cosyvoice_service.py @@ -524,6 +524,10 @@ class CosyVoiceService: if not audio_url: raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}") + # DashScope 返回 http://,统一升级为 https:// + if audio_url.startswith("http://"): + audio_url = audio_url.replace("http://", "https://", 1) + return { "task_id": "", # 同步接口无 task_id,兼容旧接口 "audio_url": audio_url, diff --git a/tests/unit/test_lipsync_routes.py b/tests/unit/test_lipsync_routes.py index b489bc028..7aa92ee60 100644 --- a/tests/unit/test_lipsync_routes.py +++ b/tests/unit/test_lipsync_routes.py @@ -1,4 +1,4 @@ -"""对口型 API 路由 + Service 单元测试 — #1796. +"""对口型 API 路由 + Service 单元测试 — #1796, #1809 参数调整. CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py """ @@ -33,6 +33,19 @@ def mock_mediakit(): return client +@pytest.fixture +def mock_cosyvoice(): + """Mock CosyVoice 服务(v3: service 内部走 submit_synthesize_task,返回 dict).""" + service = MagicMock() + service.submit_synthesize_task.return_value = { + "audio_url": "https://oss.example.com/tts-output.mp3", + "request_id": "tts-req-789", + } + # synthesize_speech 保留给直接同步调用场景 + service.synthesize_speech.return_value = MagicMock(audio_url="https://oss.example.com/tts-output.mp3") + return service + + def _make_mock_job( job_id="job-1", user_id="user-1", @@ -48,7 +61,7 @@ def _make_mock_job( m.user_id = user_id m.project_id = "" m.video_url = "https://example.com/video.mp4" - m.audio_url = "https://example.com/audio.mp3" + m.audio_url = "https://oss.example.com/tts-output.mp3" m.enable_video_loop = False m.mediakit_task_id = mediakit_task_id m.status = status @@ -64,16 +77,19 @@ def _make_mock_job( class TestSchemaValidation: - """Schema 验证测试.""" + """Schema 验证测试 — #1809 新参数结构.""" - def test_valid_video_url(self): + def test_valid_request(self): from app.schemas.lipsync import CreateLipsyncJobRequest req = CreateLipsyncJobRequest( video_url="https://example.com/video.mp4", - audio_url="https://example.com/audio.mp3", + voice_id="longxiaochun_v3", + script_text="大家好,欢迎来到直播间", ) assert req.video_url == "https://example.com/video.mp4" + assert req.voice_id == "longxiaochun_v3" + assert req.script_text == "大家好,欢迎来到直播间" def test_invalid_video_url_not_mp4(self): from app.schemas.lipsync import CreateLipsyncJobRequest @@ -81,7 +97,8 @@ class TestSchemaValidation: with pytest.raises(ValueError, match="MP4"): CreateLipsyncJobRequest( video_url="https://example.com/video.mov", - audio_url="https://example.com/audio.mp3", + voice_id="longxiaochun_v3", + script_text="测试文本", ) def test_invalid_video_url_empty(self): @@ -90,7 +107,8 @@ class TestSchemaValidation: with pytest.raises(ValueError, match="不能为空"): CreateLipsyncJobRequest( video_url=" ", - audio_url="https://example.com/audio.mp3", + voice_id="longxiaochun_v3", + script_text="测试文本", ) def test_invalid_video_url_not_http(self): @@ -99,26 +117,38 @@ class TestSchemaValidation: with pytest.raises(ValueError, match="HTTP"): CreateLipsyncJobRequest( video_url="ftp://example.com/video.mp4", - audio_url="https://example.com/audio.mp3", + voice_id="longxiaochun_v3", + script_text="测试文本", ) - def test_valid_audio_formats(self): + def test_empty_voice_id_rejected(self): from app.schemas.lipsync import CreateLipsyncJobRequest - for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]: - req = CreateLipsyncJobRequest( - video_url="https://example.com/video.mp4", - audio_url=f"https://example.com/audio{ext}", - ) - assert req.audio_url.endswith(ext) - - def test_invalid_audio_format(self): - from app.schemas.lipsync import CreateLipsyncJobRequest - - with pytest.raises(ValueError, match="格式不支持"): + with pytest.raises(ValueError, match="voice_id"): CreateLipsyncJobRequest( video_url="https://example.com/video.mp4", - audio_url="https://example.com/audio.ogg", + voice_id=" ", + script_text="测试文本", + ) + + def test_empty_script_text_rejected(self): + from app.schemas.lipsync import CreateLipsyncJobRequest + + with pytest.raises(ValueError, match="script_text"): + CreateLipsyncJobRequest( + video_url="https://example.com/video.mp4", + voice_id="longxiaochun_v3", + script_text="", + ) + + def test_script_text_too_long(self): + from app.schemas.lipsync import CreateLipsyncJobRequest + + with pytest.raises(ValueError, match="5000"): + CreateLipsyncJobRequest( + video_url="https://example.com/video.mp4", + voice_id="longxiaochun_v3", + script_text="x" * 5001, ) def test_enable_video_loop_default(self): @@ -126,7 +156,8 @@ class TestSchemaValidation: req = CreateLipsyncJobRequest( video_url="https://example.com/video.mp4", - audio_url="https://example.com/audio.mp3", + voice_id="longxiaochun_v3", + script_text="测试文本", ) assert req.enable_video_loop is False @@ -136,53 +167,145 @@ class TestSchemaValidation: req = CreateLipsyncJobRequest( video_url="https://example.com/video.mp4?token=abc", - audio_url="https://example.com/audio.mp3?sign=xyz", + voice_id="longxiaochun_v3", + script_text="测试文本", ) assert "?token=" in req.video_url + def test_dual_mode_fields_present(self): + """v3 契约: 双模式——支持直接音频 audio_url,也支持 TTS 直生 voice_id+script_text.""" + from app.schemas.lipsync import CreateLipsyncJobRequest -class TestLipsyncServiceUnit: - """Service 层单元测试(纯 mock,不依赖数据库).""" + fields = CreateLipsyncJobRequest.model_fields.keys() + # 直接音频模式 + assert "audio_url" in fields + # TTS 直生模式 + assert "voice_id" in fields + assert "script_text" in fields + # 语速/情绪透传 + assert "speed" in fields + assert "emotion" in fields - def test_create_job_success(self, mock_mediakit): - from app.services.lipsync_service import LipsyncService + def test_direct_audio_mode_accepted(self): + """v3 契约: 只传 audio_url(直接音频模式)也合法,无需 voice_id/script_text.""" + from app.schemas.lipsync import CreateLipsyncJobRequest - mock_db = MagicMock() - svc = LipsyncService(mock_db, client=mock_mediakit) - - # 模拟 db.add + db.flush 不报错 - mock_db.add = MagicMock() - mock_db.flush = MagicMock() - mock_db.commit = MagicMock() - mock_db.refresh = MagicMock() - - job = svc.create_job( - user_id="user-1", + req = CreateLipsyncJobRequest( video_url="https://example.com/video.mp4", audio_url="https://example.com/audio.mp3", ) + assert req.audio_url == "https://example.com/audio.mp3" + + def test_neither_mode_rejected(self): + """v3 契约: audio_url 与 voice_id+script_text 都缺时应报错.""" + from app.schemas.lipsync import CreateLipsyncJobRequest + + with pytest.raises(ValueError): + CreateLipsyncJobRequest(video_url="https://example.com/video.mp4") + + +class TestLipsyncServiceUnit: + """Service 层单元测试(纯 mock,不依赖数据库)— #1809 更新.""" + + def test_create_job_success(self, mock_mediakit, mock_cosyvoice): + """v3: TTS 直生——service 内部 submit_synthesize_task 合成后转存 OSS,再提交 MediaKit.""" + from app.services.lipsync_service import LipsyncService + + mock_db = MagicMock() + mock_repo = MagicMock() + mock_repo.get.return_value = None # 预置音色,原样返回 voice_id + + with ( + patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch, + patch("app.services.lipsync_service.safe_download_bytes") as dl_patch, + ): + storage_patch.return_value.upload_file.return_value = "https://my-oss/tts.mp3" + dl_patch.return_value = b"audio-bytes" + + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=mock_repo, + ) + + job = svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + voice_id="longxiaochun_v3", + script_text="大家好,欢迎来到直播间", + speed=1.2, + emotion="兴奋", + ) assert job.status == "submitted" assert job.mediakit_task_id == "mk-task-123" - mock_mediakit.submit_lipsync.assert_called_once() + # TTS 直生走 submit_synthesize_task,带语速/情绪 + mock_cosyvoice.submit_synthesize_task.assert_called_once() + _, kwargs = mock_cosyvoice.submit_synthesize_task.call_args + assert kwargs["text"] == "大家好,欢迎来到直播间" + assert kwargs["voice_id"] == "longxiaochun_v3" + assert kwargs["speed"] == 1.2 + assert kwargs["emotion"] == "excited" # 兴奋→excited + # job 记录透传字段 + assert job.speed == 1.2 + assert job.emotion == "excited" + # MediaKit 用转存后的 OSS audio_url + call_kwargs = mock_mediakit.submit_lipsync.call_args + assert call_kwargs.kwargs["audio_url"] == "https://my-oss/tts.mp3" - def test_create_job_api_failure(self, mock_mediakit): + def test_create_job_tts_failure(self, mock_mediakit): + """v3: TTS 合成失败时,CosyVoiceError 被包装为 MediaKitError(TTSSynthesisFailed), + 在建 DB 记录之前抛出,不提交 MediaKit。""" + from app.services.lipsync_service import LipsyncService + from app.services.mediakit_client import MediaKitError + from packages.application.cosyvoice_service import CosyVoiceError + + mock_cosyvoice = MagicMock() + mock_cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("Arrearage 欠费") + + mock_db = MagicMock() + mock_repo = MagicMock() + mock_repo.get.return_value = None + + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=mock_repo, + ) + + with pytest.raises(MediaKitError) as exc_info: + svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + voice_id="longxiaochun_v3", + script_text="测试文本", + ) + assert exc_info.value.code == "TTSSynthesisFailed" + + # 不应提交到 MediaKit + mock_mediakit.submit_lipsync.assert_not_called() + + def test_create_job_api_failure(self, mock_mediakit, mock_cosyvoice): + """MediaKit 提交失败.""" from app.services.lipsync_service import LipsyncService from app.services.mediakit_client import MediaKitError mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed") mock_db = MagicMock() - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) with pytest.raises(MediaKitError, match="API 调用失败"): svc.create_job( user_id="user-1", video_url="https://example.com/video.mp4", - audio_url="https://example.com/audio.mp3", + voice_id="longxiaochun_v3", + script_text="测试文本", ) - def test_get_job_delegates_to_db(self, mock_mediakit): + def test_get_job_delegates_to_db(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService mock_job = _make_mock_job() @@ -193,13 +316,13 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.get_job("job-1", "user-1") assert result is mock_job mock_db.query.assert_called_once() - def test_get_job_not_found(self, mock_mediakit): + def test_get_job_not_found(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService mock_db = MagicMock() @@ -209,11 +332,11 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.get_job("nonexistent", "user-1") assert result is None - def test_refresh_job_completed(self, mock_mediakit): + def test_refresh_job_completed(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService mock_job = _make_mock_job(status="submitted") @@ -224,14 +347,14 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.refresh_job_status("job-1", "user-1") assert result.status == "completed" assert result.output_video_url == "https://output.mp4" assert result.output_duration == 30.0 - def test_refresh_job_failed(self, mock_mediakit): + def test_refresh_job_failed(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService mock_mediakit.get_task_status.return_value = { @@ -251,13 +374,13 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.refresh_job_status("job-1", "user-1") assert result.status == "failed" assert result.error_code == "DownloadFailed" - def test_refresh_job_already_completed(self, mock_mediakit): + def test_refresh_job_already_completed(self, mock_mediakit, mock_cosyvoice): """已完成的任务不轮询.""" from app.services.lipsync_service import LipsyncService @@ -269,14 +392,14 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.refresh_job_status("job-1", "user-1") # 不应调用 MediaKit mock_mediakit.get_task_status.assert_not_called() assert result.status == "completed" - def test_cancel_job_pending(self, mock_mediakit): + def test_cancel_job_pending(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService mock_job = _make_mock_job(status="pending") @@ -287,12 +410,12 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.cancel_job("job-1", "user-1") assert result.status == "cancelled" - def test_cancel_job_completed_not_allowed(self, mock_mediakit): + def test_cancel_job_completed_not_allowed(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService mock_job = _make_mock_job(status="completed") @@ -303,8 +426,172 @@ class TestLipsyncServiceUnit: mock_query.filter.return_value = mock_filter mock_db.query.return_value = mock_query - svc = LipsyncService(mock_db, client=mock_mediakit) + svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice) result = svc.cancel_job("job-1", "user-1") # 已完成不可取消 assert result.status == "completed" + + def test_create_job_stores_tts_audio_url(self, mock_mediakit, mock_cosyvoice): + """v3: TTS 直生模式下 job.audio_url 为转存到自家 OSS 的永久地址.""" + from app.services.lipsync_service import LipsyncService + + mock_db = MagicMock() + mock_repo = MagicMock() + mock_repo.get.return_value = None + + with ( + patch("app.services.lipsync_service.get_shared_storage_service") as storage_patch, + patch("app.services.lipsync_service.safe_download_bytes") as dl_patch, + ): + storage_patch.return_value.upload_file.return_value = "https://my-oss/permanent.mp3" + dl_patch.return_value = b"audio-bytes" + + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=mock_repo, + ) + job = svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + voice_id="my-clone-voice", + script_text="这是一段测试文本", + ) + + # job.audio_url 是转存 OSS 后的永久地址 + assert job.audio_url == "https://my-oss/permanent.mp3" + + def test_create_job_direct_audio_skips_tts(self, mock_mediakit, mock_cosyvoice): + """v3: 直接音频模式(传 audio_url)不触发 TTS,原样把 audio_url 提交 MediaKit.""" + from app.services.lipsync_service import LipsyncService + + mock_db = MagicMock() + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=MagicMock(), + ) + job = svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + audio_url="https://example.com/direct-audio.mp3", + ) + + mock_cosyvoice.submit_synthesize_task.assert_not_called() + call_kwargs = mock_mediakit.submit_lipsync.call_args + assert call_kwargs.kwargs["audio_url"] == "https://example.com/direct-audio.mp3" + assert job.audio_url == "https://example.com/direct-audio.mp3" + + +class TestErrorHandling: + """v3: 音色解析与错误码在 service 层处理,路由层做 HTTP 状态码映射.""" + + def test_voice_id_resolve_forbidden(self, mock_mediakit, mock_cosyvoice): + """v3: 克隆音色属于他人时 service._resolve_voice_id 抛 VoiceForbidden(路由映射 403).""" + from app.services.lipsync_service import LipsyncService + from app.services.mediakit_client import MediaKitError + + mock_db = MagicMock() + mock_repo = MagicMock() + other_profile = MagicMock() + other_profile.user_id = "user-other" + other_profile.voice_id = "cv-voice-1" + mock_repo.get.return_value = other_profile + + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=mock_repo, + ) + + with pytest.raises(MediaKitError) as exc_info: + svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + voice_id="clone-profile-id", + script_text="测试", + ) + assert exc_info.value.code == "VoiceForbidden" + mock_mediakit.submit_lipsync.assert_not_called() + + def test_voice_id_resolve_not_ready(self, mock_mediakit, mock_cosyvoice): + """v3: 克隆音色尚未生成 voice_id 时抛 VoiceNotReady(路由映射 400).""" + from app.services.lipsync_service import LipsyncService + from app.services.mediakit_client import MediaKitError + + mock_db = MagicMock() + mock_repo = MagicMock() + profile = MagicMock() + profile.user_id = "user-1" + profile.voice_id = "" # 克隆未完成 + mock_repo.get.return_value = profile + + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=mock_repo, + ) + + with pytest.raises(MediaKitError) as exc_info: + svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + voice_id="clone-profile-id", + script_text="测试", + ) + assert exc_info.value.code == "VoiceNotReady" + + def test_tts_value_error_mapped_to_invalid_param(self, mock_mediakit): + """v3: CosyVoice 抛 ValueError(参数无效)被包装为 TTSInvalidParam(路由映射 400).""" + from app.services.lipsync_service import LipsyncService + from app.services.mediakit_client import MediaKitError + + mock_cosyvoice = MagicMock() + mock_cosyvoice.submit_synthesize_task.side_effect = ValueError("voice_id 为空") + + mock_db = MagicMock() + mock_repo = MagicMock() + mock_repo.get.return_value = None + + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=mock_repo, + ) + + with pytest.raises(MediaKitError) as exc_info: + svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + voice_id="some-voice", + script_text="test", + ) + assert exc_info.value.code == "TTSInvalidParam" + + def test_missing_both_inputs_raises_invalid_input(self, mock_mediakit, mock_cosyvoice): + """v3: 既无 audio_url 又无 voice_id+script_text 时抛 InvalidInput(路由映射 400).""" + from app.services.lipsync_service import LipsyncService + from app.services.mediakit_client import MediaKitError + + mock_db = MagicMock() + svc = LipsyncService( + mock_db, + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=MagicMock(), + ) + + with pytest.raises(MediaKitError) as exc_info: + svc.create_job( + user_id="user-1", + video_url="https://example.com/video.mp4", + ) + assert exc_info.value.code == "InvalidInput" + mock_cosyvoice.submit_synthesize_task.assert_not_called() + mock_mediakit.submit_lipsync.assert_not_called()