Files
meijiaka-zy/python-api/app/services/vidu_service.py
T
小鱼开发 8f8256ddfb fix: 声音克隆暴露原始错误 + 脚本生成去掉中间进度提示
- voice.py: 异常处理不再吞掉原始错误,直接暴露具体原因
- vidu_service.py: clone_voice 错误消息包含 Vidu 返回的 error_message
- ScriptCreation.tsx: 去掉一闪而过的'任务已创建,等待执行...'中间状态
2026-05-06 11:04:26 +08:00

276 lines
8.7 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.
"""
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 的内部任务 IDcallback 场景必须传入,
确保 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,
}