Files
meijiaka-zy/python-api/app/api/v1/vidu.py
T
小鱼开发 4e06f4abe2 feat: 空镜素材配置后端化,视频生成流程重构
- 后端: 空镜素材迁移到 config/materials.json,duration从文件名_{N}s_自动解析
- 后端: 新增 POST /api/v1/materials/match 接口,后端做关键词匹配
- 前端: VideoGeneration 空镜匹配改为调用后端接口
- 前端: 人物出镜素材改为本地文件选择器直接选取,不走素材库
- 前端: 视频生成流程简化,移除Vidu对口型和七牛云上传
- Rust: 视频合成支持从随机起始时间截取人物素材片段
- Rust: 修复ffprobe参数错误(添加-show_entries format=duration)
2026-04-22 18:49:20 +08:00

160 lines
5.4 KiB
Python

"""
Vidu API 代理路由
================
提供 Vidu 对口型(lip-sync)任务的提交和查询接口。
前端通过此接口提交任务并轮询状态,无需直接访问 Vidu API。
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success_response
from app.services.vidu_tts_service import ViduTTSService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vidu", tags=["Vidu"])
# ========== 请求/响应模型 ==========
class LipSyncRequest(BaseModel):
"""对口型请求"""
video_url: str = Field(..., min_length=1, description="原视频 URL")
audio_url: str | None = Field(None, description="音频 URL(与 text 二选一)")
text: str | None = Field(None, description="文本内容(与 audio_url 二选一)")
voice_id: str | None = Field(None, description="音色 ID(文字驱动时生效)")
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="语速")
volume: int = Field(default=0, ge=0, le=10, description="音量")
ref_photo_url: str | None = Field(None, description="人脸参考图 URL")
@staticmethod
def validate_at_least_one_audio_source(values: dict) -> dict:
"""验证至少提供 audio_url 或 text 之一"""
audio_url = values.get("audio_url")
text = values.get("text")
if not audio_url and not text:
raise ValueError("必须提供 audio_url 或 text 之一")
return values
class LipSyncResponse(BaseModel):
"""对口型任务提交响应"""
task_id: str = Field(..., description="Vidu 任务 ID")
message: str = Field(default="任务已提交", description="状态消息")
class LipSyncQueryResponse(BaseModel):
"""对口型任务查询响应"""
task_id: str = Field(..., description="任务 ID")
state: str = Field(..., description="任务状态: pending/processing/succeeded/failed")
video_url: str | None = Field(None, description="生成后的视频 URL(成功时)")
message: str | None = Field(None, description="状态描述或错误信息")
creations: list[dict] | None = Field(None, description="Vidu 原始 creations 数据")
# ========== API 路由 ==========
@router.post("/lip-sync", response_model=ApiResponse[LipSyncResponse])
async def create_lip_sync_task(
request: LipSyncRequest,
current_user: User = Depends(get_current_user),
):
"""
提交 Vidu 对口型任务
需要提供:
- video_url: 原视频 URL(人物出镜视频)
- audio_url: 音频 URL(与 text 二选一)
- text: 文本内容(与 audio_url 二选一,使用 Vidu 内置 TTS)
返回 Vidu task_id,用于后续轮询查询。
"""
try:
# 验证至少提供一种音频来源
if not request.audio_url and not request.text:
raise HTTPException(
status_code=400,
detail="必须提供 audio_url 或 text 之一",
)
service = ViduTTSService()
task_id = await service.lip_sync_create(
video_url=request.video_url,
audio_url=request.audio_url,
text=request.text,
voice_id=request.voice_id,
speed=request.speed,
volume=request.volume,
ref_photo_url=request.ref_photo_url,
)
logger.info(f"[Vidu] 对口型任务提交成功: task_id={task_id}, user={current_user.id}")
return success_response(
data=LipSyncResponse(
task_id=task_id,
message="对口型任务已提交,请轮询查询状态",
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Vidu] 提交对口型任务失败: {e}")
raise HTTPException(status_code=500, detail=f"提交对口型任务失败: {e}")
@router.get("/tasks/{task_id}/creations", response_model=ApiResponse[LipSyncQueryResponse])
async def query_lip_sync_task(
task_id: str,
current_user: User = Depends(get_current_user),
):
"""
查询 Vidu 对口型任务状态
返回任务状态及生成物信息。
当 state=succeeded 时,video_url 为生成后的对口型视频 URL。
"""
try:
service = ViduTTSService()
result = await service.lip_sync_query(task_id)
state = result.get("state", "unknown")
creations = result.get("creations", [])
# 提取视频 URL(成功时)
video_url = None
if state == "succeeded" and creations:
# creations 是数组,取第一个
first_creation = creations[0] if creations else {}
video_url = first_creation.get("url")
logger.info(
f"[Vidu] 查询对口型任务: task_id={task_id}, state={state}, user={current_user.id}"
)
return success_response(
data=LipSyncQueryResponse(
task_id=task_id,
state=state,
video_url=video_url,
message=result.get("message") if state == "failed" else None,
creations=creations if creations else None,
)
)
except Exception as e:
logger.error(f"[Vidu] 查询对口型任务失败: {e}")
raise HTTPException(status_code=500, detail=f"查询任务失败: {e}")