053b00634a
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Validate - Style (push) Successful in 4m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m26s
CI/CD Pipeline / Unit Tests (push) Successful in 8m55s
CI/CD Pipeline / Validate - Security (push) Successful in 9m7s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 82h52m10s
CI/CD Pipeline / Deploy Production (push) Failing after 82h52m6s
CI/CD Pipeline / Frontend Lint (push) Failing after 83h1m19s
CI/CD Pipeline / PR Build Web Image (push) Failing after 83h0m59s
CI/CD Pipeline / PR Build API Image (push) Failing after 83h0m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 83h0m28s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 83h0m28s
CI/CD Pipeline / CI Gate (push) Failing after 82h51m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 83h1m1s
CI/CD Pipeline / Canary Release to Production (push) Failing after 82h51m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 82h51m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 82h51m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 83h0m59s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 83h0m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
236 lines
8.2 KiB
Python
236 lines
8.2 KiB
Python
"""对口型 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 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),
|
||
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)。
|
||
"""
|
||
try:
|
||
job = svc.create_job(
|
||
user_id=current_user.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,
|
||
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),
|
||
svc: LipsyncService = Depends(_get_service),
|
||
):
|
||
"""步骤1「生成配音」同步 TTS 预合成.
|
||
|
||
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
|
||
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
|
||
耗时约 2-3 秒。
|
||
"""
|
||
try:
|
||
result = svc.preview_tts(
|
||
user_id=current_user.user.id,
|
||
voice_id=body.voice_id,
|
||
script_text=body.script_text,
|
||
speed=body.speed,
|
||
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"):
|
||
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 状态可取消)."""
|
||
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 可取消",
|
||
)
|
||
return job
|