Files
meijiaka-zy/python-api/app/services/vidu_service.py
T
小鱼开发 04e467e433 feat(points): 积分系统收尾 + 充值弹窗改造 + 命名统一
后端:
- 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试
- recharge() 加 order_id 幂等保护,防重复充值
- time_expire 使用北京时间(UTC+8),修复时区 bug
- 充值档位后端配置化(points-config.yaml + /recharge-options API)
- 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等)

前端:
- 充值弹窗:自动轮询 + 【我已支付】手动兜底
- 二维码倒计时显示,过期后遮罩 + 刷新按钮
- 充值档位从后端动态加载
- 去掉 select/qrcode 弹窗标题,金额红色突出显示
- 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等)
- Modal 关闭按钮独立于 title 显示
2026-05-09 21:29:35 +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,
}