30536276ba
核心变更:
- 统一第三方接口架构:所有服务走 PlatformGateway(call_sync/submit_task/query_task/handle_webhook)
- 视频生成(Vidu 对口型)纳入 Async Engine,与 script/subtitle/tts 统一为 POST /tasks/{task_type} 模式
- 新增 VideoHandler、TTSHandler,完善 ScriptHandler/SubtitleHandler
- PlatformGateway 生成 internal_task_id,建立 Redis 双向映射,callback 场景传入 Async Engine task_id 保证映射一致
- SlotManager 新增 acquire_ctx 上下文管理器,所有 Handler 统一使用
- ViduAdapter 状态映射归一化(normalize_state/denormalize_state)
- 移除 ViduService Semaphore 和 tenacity 重试,并发控制完全交予 SlotManager
- nonce 防重放下沉到 CallbackCapable 协议
- Service 层错误统一为 PlatformError,路由层错误信息脱敏
- 废弃 /voice/lip-sync,清理 vidu.py 遗留路由
Bug 修复:
- VideoHandler 轮询阶段后添加 continue,防止已提交任务重复创建
- voice.py synthesize_to_file 变量名冲突(request vs request_body)
- PlatformGateway.submit_task 空 data 防护
- ScriptHandler 动态导入 asyncio 改为模块级导入
- SubtitleHandler 完成时补充 progress=100
文档:
- 更新 AGENTS.md 核心功能、运行时架构、异步调度描述
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
"""
|
|
火山引擎字幕 Adapter
|
|
====================
|
|
|
|
实现 PlatformAdapter + TaskCapable。
|
|
直接接入 VolcengineCaptionProvider,提供标准 Protocol 接口。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from app.ai.adapters.base import AdapterResponse, PlatformAdapter, TaskCapable, TaskStatus
|
|
from app.ai.adapters.constants import Method
|
|
from app.ai.providers.volcengine_caption_provider import VolcengineCaptionProvider
|
|
from app.core.exceptions import PlatformError, PlatformErrorType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class VolcengineCaptionAdapter(PlatformAdapter, TaskCapable):
|
|
"""火山引擎字幕平台标准 Adapter"""
|
|
|
|
platform_id = "volcengine_caption"
|
|
|
|
def __init__(self, provider: VolcengineCaptionProvider):
|
|
self.provider = provider
|
|
|
|
# ── PlatformAdapter ──
|
|
|
|
async def health(self) -> AdapterResponse:
|
|
try:
|
|
# 火山字幕没有专门的健康检查,用提交一个无效任务测试连通性
|
|
# 401/403 说明网络通但认证问题,也算"可用"
|
|
await self.provider.submit_caption_task(audio_url="https://example.com/test.mp3")
|
|
return AdapterResponse(success=True)
|
|
except PlatformError as e:
|
|
if e.error_type in (PlatformErrorType.AUTH_FAILED, PlatformErrorType.BAD_REQUEST):
|
|
return AdapterResponse(success=True)
|
|
return AdapterResponse(
|
|
success=False,
|
|
error_message=str(e),
|
|
retryable=e.retryable,
|
|
)
|
|
except Exception as e:
|
|
return AdapterResponse(
|
|
success=False,
|
|
error_message=str(e),
|
|
retryable=False,
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
await self.provider.close()
|
|
|
|
# ── TaskCapable ──
|
|
|
|
async def submit(
|
|
self,
|
|
task_type: str,
|
|
payload: dict[str, Any],
|
|
callback_url: str | None = None,
|
|
) -> AdapterResponse:
|
|
try:
|
|
if task_type == Method.CAPTION:
|
|
result = await self.provider.submit_caption_task(
|
|
audio_url=payload["audio_url"],
|
|
language=payload.get("language", "zh-CN"),
|
|
caption_type=payload.get("caption_type", "auto"),
|
|
use_punc=payload.get("use_punc", True),
|
|
use_itn=payload.get("use_itn", True),
|
|
words_per_line=payload.get("words_per_line", 46),
|
|
max_lines=payload.get("max_lines", 1),
|
|
)
|
|
return AdapterResponse(success=True, data={"task_id": result["id"]})
|
|
|
|
elif task_type == Method.AUTO_ALIGN:
|
|
result = await self.provider.submit_auto_align_task(
|
|
audio_url=payload["audio_url"],
|
|
audio_text=payload["audio_text"],
|
|
caption_type=payload.get("caption_type", "speech"),
|
|
sta_punc_mode=payload.get("sta_punc_mode", 3),
|
|
)
|
|
return AdapterResponse(success=True, data={"task_id": result["id"]})
|
|
|
|
else:
|
|
return AdapterResponse(
|
|
success=False,
|
|
error_message=f"不支持的任务类型: {task_type}",
|
|
retryable=False,
|
|
)
|
|
|
|
except PlatformError:
|
|
raise
|
|
except Exception as e:
|
|
raise PlatformError(
|
|
f"火山字幕 {task_type} 提交失败: {e}",
|
|
platform="volcengine_caption",
|
|
retryable=False,
|
|
error_type=PlatformErrorType.UNKNOWN,
|
|
) from e
|
|
|
|
async def query(self, platform_task_id: str) -> TaskStatus:
|
|
"""查询字幕任务状态(caption 类型)"""
|
|
try:
|
|
data = await self.provider.query_caption_task(platform_task_id, blocking=False)
|
|
return self._parse_status(data)
|
|
except Exception:
|
|
raise
|
|
|
|
async def query_auto_align(self, platform_task_id: str) -> TaskStatus:
|
|
"""查询打轴任务状态(auto_align 类型)"""
|
|
try:
|
|
data = await self.provider.query_auto_align_task(platform_task_id, blocking=False)
|
|
return self._parse_status(data)
|
|
except Exception:
|
|
raise
|
|
|
|
def _parse_status(self, data: dict) -> TaskStatus:
|
|
"""解析火山字幕原始响应为统一 TaskStatus"""
|
|
code = data.get("code", -1)
|
|
if code == 0:
|
|
utterances = data.get("utterances", [])
|
|
return TaskStatus(
|
|
state="completed",
|
|
result={
|
|
"duration": data.get("duration", 0.0),
|
|
"utterances": [
|
|
{
|
|
"text": u.get("text", ""),
|
|
"start_time": u.get("start_time", 0) or u.get("startTime", 0),
|
|
"end_time": u.get("end_time", 0) or u.get("endTime", 0),
|
|
}
|
|
for u in utterances
|
|
],
|
|
},
|
|
)
|
|
elif code == 2000:
|
|
return TaskStatus(state="processing")
|
|
else:
|
|
return TaskStatus(
|
|
state="failed",
|
|
error_message=data.get("message", f"未知错误: {code}"),
|
|
)
|