4e06f4abe2
- 后端: 空镜素材迁移到 config/materials.json,duration从文件名_{N}s_自动解析
- 后端: 新增 POST /api/v1/materials/match 接口,后端做关键词匹配
- 前端: VideoGeneration 空镜匹配改为调用后端接口
- 前端: 人物出镜素材改为本地文件选择器直接选取,不走素材库
- 前端: 视频生成流程简化,移除Vidu对口型和七牛云上传
- Rust: 视频合成支持从随机起始时间截取人物素材片段
- Rust: 修复ffprobe参数错误(添加-show_entries format=duration)
160 lines
5.4 KiB
Python
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}")
|