Files
xiaoxia-saas/apps/api/app/api/routes/lipsync.py
T
CI Bot 7a63905a1c
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m54s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 16m34s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 17m23s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 18m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 21m15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 29s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m21s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 35m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 10s
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-10-02 12:57:10 +00:00

269 lines
9.9 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.
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整, #1845 配音前置.
接口:
POST /api/v1/lipsync/jobs 提交对口型任务(支持 TTS/直传/预合成 三种模式)
GET /api/v1/lipsync/jobs 任务列表
GET /api/v1/lipsync/jobs/{id} 任务详情
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
POST /api/v1/lipsync/tts-preview #1845 步骤1 TTS 预合成(同步 HTTP,~2-3s)
"""
from __future__ import annotations
import logging
from datetime import UTC
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_db_session,
get_voice_clone_profile_repository,
)
from app.schemas.lipsync import (
AiAvatarTtsPreviewRequest,
AiAvatarTtsPreviewResponse,
CreateLipsyncJobRequest,
LipsyncJobResponse,
)
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_service(
db: Session = Depends(get_db_session),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> LipsyncService:
# voice_clone_repo 用于克隆音色 profile 解析
return LipsyncService(
db,
voice_clone_repo=voice_clone_repo,
)
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
@router.post("/jobs", response_model=LipsyncJobResponse, status_code=201)
def create_lipsync_job(
body: CreateLipsyncJobRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
"""提交对口型任务.
三种模式:
- TTS 直生(旧版/降级):传 {video_url, voice_id, script_text, speed?, emotion?},
后端 dispatch Celery 异步任务。
- 直接音频:传 {video_url, audio_url},后端同步下载+算timings+提交MediaKit。
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings},
后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。
"""
user_id = current_user.user.id
try:
job = svc.create_job(
user_id=user_id,
video_url=body.video_url,
audio_url=body.audio_url,
audio_duration=body.audio_duration,
sentence_timings=body.sentence_timings,
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MediaKitError as exc:
status_code = 502
if exc.code in ("VoiceForbidden",):
status_code = 403
elif exc.code in ("InvalidInput", "TTSInvalidParam", "VoiceNotReady"):
status_code = 400
raise HTTPException(
status_code=status_code,
detail={
"code": exc.code,
"message": str(exc),
"request_id": getattr(exc, "request_id", ""),
},
) from exc
except Exception as exc:
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
raise HTTPException(
status_code=400,
detail=f"创建对口型任务失败: {exc}",
) from exc
return job
# ── POST /tts-preview — #1845 步骤1 TTS 预合成 ──────────────────────────
@router.post("/tts-preview", response_model=AiAvatarTtsPreviewResponse)
def preview_tts(
body: AiAvatarTtsPreviewRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
"""步骤1「生成配音」同步 TTS 预合成.
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
耗时约 2-3 秒。
"""
user_id = current_user.user.id
try:
result = svc.preview_tts(
user_id=user_id,
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
)
except MediaKitError as exc:
status_code = 400
if exc.code in ("VoiceForbidden",):
status_code = 403
elif exc.code in ("TTSNoAudio",):
status_code = 502
raise HTTPException(
status_code=status_code,
detail={
"code": exc.code,
"message": str(exc),
},
) from exc
except Exception as exc:
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
raise HTTPException(
status_code=400,
detail=f"TTS 合成失败: {exc}",
) from exc
return result
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@router.get("/jobs", response_model=dict)
def list_lipsync_jobs(
project_id: str = Query("", description="项目 ID 过滤"),
status: str = Query("", description="状态过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务列表."""
items, total = svc.list_jobs(
user_id=current_user.user.id,
project_id=project_id,
status=status,
offset=offset,
limit=limit,
)
return {
"items": [LipsyncJobResponse.model_validate(j) for j in items],
"total": total,
"offset": offset,
"limit": limit,
}
# ── GET /jobs/{job_id} — 任务详情 ────────────────────────────────────────
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
def get_lipsync_job(
job_id: str,
background: BackgroundTasks,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务详情."""
job = svc.get_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status not in ("completed", "failed"):
# 三层防御 ①:如果距上次更新超过 30 秒,同步刷新一次(避免 background task
# 静默失败导致前端永远看到 running);否则挂后台异步刷新(避免阻塞轮询)。
from datetime import datetime as _dt
_now = _dt.now(UTC)
_upd = job.updated_at
# DB 返回的 DateTime 列可能是 naive(取决于方言/驱动):代码写入统一用
# datetime.now(UTC),经 SQLAlchemy 存入 TIMESTAMP WITHOUT TIMEZONE 后再
# 读回就是 UTC wall clock 的 naive datetime,直接补 UTC tz 即可;避免
# TypeError: can't subtract offset-naive and offset-aware datetimes。
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
_stale = _upd is None or (_now - _upd).total_seconds() > 30
if _stale:
try:
refreshed = svc.refresh_job_status(job_id, current_user.user.id)
if refreshed is not None:
job = refreshed
except Exception as exc: # noqa: BLE001
logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True)
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
else:
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
return job
# ── POST /jobs/{job_id}/refresh — 刷新状态 ───────────────────────────────
@router.post("/jobs/{job_id}/refresh", response_model=LipsyncJobResponse)
def refresh_lipsync_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""从 MediaKit 拉取最新状态并更新."""
job = svc.refresh_job_status(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
return job
# ── POST /jobs/{job_id}/cancel — 取消任务 ────────────────────────────────
@router.post("/jobs/{job_id}/cancel", response_model=LipsyncJobResponse)
def cancel_lipsync_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""取消对口型任务(仅 pending/tts_processing/submitted/processing 状态可取消)."""
job = svc.cancel_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status != "cancelled":
raise HTTPException(
status_code=400,
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted/processing 可取消",
)
return job