279 lines
8.8 KiB
Python
279 lines
8.8 KiB
Python
"""
|
||
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,
|
||
)
|
||
|
||
clone_data = result.data or {}
|
||
logger.info(f"[Vidu Clone] 复刻成功: voice_id={clone_data.get('voice_id')}")
|
||
return clone_data
|
||
|
||
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,
|
||
}
|