""" Vidu 业务服务封装 ================== 通过 PlatformGateway 统一调用 Vidu API。 职责: - 业务参数校验与默认值处理 - 结果提取与格式化 """ from __future__ import annotations import logging from typing import Any from fastapi import Request from app.ai.adapters.constants import Method from app.ai.adapters.vidu_adapter import ViduAdapter from app.core.exceptions import PlatformError, PlatformErrorType from app.platform_gateway import PlatformGateway logger = logging.getLogger(__name__) # Vidu 预设音色 VIDU_PRESET_VOICES = [ { "voice_id": "tianxin_xiaoling", "name": "甜心小玲", "language": "zh", "description": "甜美可爱,活泼俏皮", "recommended": True, "previewUrl": "https://media.liche.cn/meijiaka-zy/voice/tianxin_xiaoling.mp3", }, { "voice_id": "danya_xuejie", "name": "淡雅学姐", "language": "zh", "description": "淡雅知性,温婉柔和", "recommended": False, "previewUrl": "https://media.liche.cn/meijiaka-zy/voice/danya_xuejie.mp3", }, { "voice_id": "Chinese (Mandarin)_Warm_Girl", "name": "温暖少女", "language": "zh", "description": "温暖亲切,清新自然", "recommended": False, "previewUrl": "https://media.liche.cn/meijiaka-zy/voice/Warm_Girl.mp3", }, { "voice_id": "Chinese (Mandarin)_Radio_Host", "name": "电台男主播", "language": "zh", "description": "专业播报,沉稳有力", "recommended": False, "previewUrl": "https://media.liche.cn/meijiaka-zy/voice/Radio_Host.mp3", }, { "voice_id": "Chinese (Mandarin)_Straightforward_Boy", "name": "率真弟弟", "language": "zh", "description": "率真爽朗,青春阳光", "recommended": False, "previewUrl": "https://media.liche.cn/meijiaka-zy/voice/Straightforward_Boy.mp3", }, { "voice_id": "Chinese (Mandarin)_Gentleman", "name": "温润男声", "language": "zh", "description": "温润如玉,低沉磁性", "recommended": False, "previewUrl": "https://media.liche.cn/meijiaka-zy/voice/Gentleman.mp3", }, ] DEFAULT_VOICE_ID = "tianxin_xiaoling" # ==================== 预设音色(模块级函数,不依赖 Service 实例)==================== def get_preset_voices() -> list[dict]: """获取预设音色列表""" return VIDU_PRESET_VOICES def get_voice_by_id(voice_id: str) -> dict | None: """根据 ID 获取音色信息""" for voice in VIDU_PRESET_VOICES: if voice["voice_id"] == voice_id: return voice return None # ==================== Service 层 ==================== async def get_vidu_service(request: Request) -> ViduService: """FastAPI Depends:从 app.state 获取全局 ViduService 实例。""" return request.app.state.vidu_service class ViduService: """Vidu 业务服务封装 通过 PlatformGateway 统一调用,自身负责业务参数校验与结果提取。 并发控制由调用方(Scheduler SlotManager 或 API 层 Semaphore)负责。 """ def __init__(self, gateway: PlatformGateway): self.gateway = gateway async def synthesize( self, text: str, voice_id: str | None = None, speed: float = 1.0, volume: int = 0, pitch: int = 0, **kwargs, ) -> str: """同步语音合成,返回音频 URL。""" if not text or not text.strip(): raise PlatformError( "text 不能为空", platform="vidu", retryable=False, error_type=PlatformErrorType.BAD_REQUEST, ) voice = voice_id or DEFAULT_VOICE_ID result = await self.gateway.call_sync( platform="vidu", method=Method.TTS, payload={ "text": text, "voice_id": voice, "speed": speed, "volume": volume, "pitch": pitch, **kwargs, }, ) if not result.success: # 原始错误记录日志,前端只收到友好提示 logger.error(f"[Vidu TTS] 合成失败: {result.error_message}") raise PlatformError( "语音合成失败,请稍后重试", platform="vidu", retryable=result.retryable, error_type=PlatformErrorType.BAD_REQUEST, ) audio_url = result.data.get("audio_url") if result.data else None if not audio_url: logger.error("[Vidu TTS] 合成成功但未返回音频 URL") raise PlatformError( "语音合成结果异常,请稍后重试", platform="vidu", retryable=False, error_type=PlatformErrorType.BAD_REQUEST, ) logger.info(f"[Vidu TTS] 合成成功: voice_id={voice}, url={audio_url[:60]}...") return audio_url # ==================== 声音复刻 ==================== async def clone_voice( self, audio_url: str, voice_id: str, text: str | None = None, prompt_audio_url: str | None = None, prompt_text: str | None = None, ) -> dict[str, Any]: """声音复刻(同步接口)。 Returns: 复刻结果 dict,包含 voice_id、demo_audio 等 """ trial_text = text or "你好,欢迎使用vidu开放平台" result = await self.gateway.call_sync( platform="vidu", method=Method.CLONE_VOICE, payload={ "audio_url": audio_url, "voice_id": voice_id, "text": trial_text, "prompt_audio_url": prompt_audio_url, "prompt_text": prompt_text, }, ) if not result.success: logger.error(f"[Vidu Clone] 复刻失败: {result.error_message}") raise PlatformError( f"声音复刻失败: {result.error_message or '请稍后重试'}", platform="vidu", retryable=result.retryable, error_type=PlatformErrorType.BAD_REQUEST, ) logger.info(f"[Vidu Clone] 复刻成功: voice_id={result.data.get('voice_id')}") return result.data or {} async def query_clone_task(self, voice_id: str) -> dict[str, Any]: """Vidu 声音复刻是同步接口,无独立查询。 此方法仅做兼容,返回已知的 voice_id 信息。 """ return {"voice_id": voice_id, "status": "succeeded"} # ==================== 视频生成 ==================== async def lip_sync_create( self, video_url: str, audio_url: str | None = None, text: str | None = None, voice_id: str | None = None, speed: float = 1.0, volume: int = 0, ref_photo_url: str | None = None, callback_url: str | None = None, task_id: str | None = None, ) -> str: """创建视频生成任务(异步接口),返回 task_id。 Args: task_id: Async Engine 的内部任务 ID,callback 场景必须传入, 确保 PlatformGateway 的映射与 Registry 中的 task key 一致。 """ result = await self.gateway.submit_task( platform="vidu", task_type=Method.LIP_SYNC, payload={ "video_url": video_url, "audio_url": audio_url, "text": text, "voice_id": voice_id, "speed": speed, "volume": volume, "ref_photo_url": ref_photo_url, }, callback_url=callback_url, internal_task_id=task_id, ) if not result: raise PlatformError( "视频生成任务创建失败,请稍后重试", platform="vidu", retryable=False, error_type=PlatformErrorType.BAD_REQUEST, ) logger.info(f"[Vidu LipSync] 任务创建成功: task_id={result}") return result async def lip_sync_query(self, task_id: str) -> dict[str, Any]: """查询视频生成任务状态及生成物(task_id 为内部 ID)。""" status = await self.gateway.query_task_by_internal_id(task_id) result_data = status.result or {} return { "state": ViduAdapter.denormalize_state(status.state), "creations": [{"url": result_data.get("video_url")}] if result_data.get("video_url") else [], "message": status.error_message, }