115b428cb3
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m55s
CI/CD Pipeline / Validate - Style (push) Successful in 10m36s
CI/CD Pipeline / Integration Tests (push) Successful in 12m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 12m51s
CI/CD Pipeline / Unit Tests (push) Successful in 16m33s
CI/CD Pipeline / Validate - Security (push) Successful in 30m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Postgres TIMESTAMP WITHOUT TIMEZONE 返回 naive datetime,路由层 (_now - job.updated_at).total_seconds() 抛 TypeError: can't subtract offset-naive and offset-aware datetimes,被全局异常 handler 吞为 500 INTERNAL_ERROR,前端持续轮询一直报错。 修复:比较前把 naive datetime 当作 UTC wall clock 补 tzinfo(与代码写入 default=datetime.now(UTC) 一致),补 2 个单测覆盖 naive stale/fresh 两个分支。 176 tests passed.
355 lines
14 KiB
Python
355 lines
14 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
|
||
import math
|
||
from datetime import UTC
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.config import settings
|
||
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
|
||
|
||
from packages.domain.points_rules import calculate_points_cost
|
||
from packages.domain.points_service import PointsService
|
||
|
||
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),
|
||
):
|
||
user_id = current_user.user.id
|
||
|
||
# ── 积分扣点(#1895 P2) ──
|
||
_points_deducted = 0
|
||
_points_scene = "ai_digital_human"
|
||
_points_svc = PointsService() if settings.points_enabled else None
|
||
if _points_svc is not None:
|
||
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
|
||
if body.audio_url and body.audio_duration and body.audio_duration > 0:
|
||
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
|
||
elif body.script_text:
|
||
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
|
||
else:
|
||
est_minutes = 1.0
|
||
_points_deducted = calculate_points_cost(
|
||
_points_scene,
|
||
is_member=getattr(current_user.user, "is_member", False),
|
||
duration_minutes=est_minutes,
|
||
member_type=getattr(current_user.user, "member_type", None),
|
||
)
|
||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||
if not _deduct_res["success"]:
|
||
raise HTTPException(
|
||
status_code=402,
|
||
detail={
|
||
"code": "INSUFFICIENT_POINTS",
|
||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||
"required": _points_deducted,
|
||
"balance": _deduct_res["balance"],
|
||
},
|
||
)
|
||
"""提交对口型任务.
|
||
|
||
三种模式:
|
||
- 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=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:
|
||
if _points_deducted > 0 and _points_svc is not None:
|
||
try:
|
||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||
except Exception as refund_err:
|
||
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
except MediaKitError as exc:
|
||
if _points_deducted > 0 and _points_svc is not None:
|
||
try:
|
||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||
except Exception as refund_err:
|
||
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
|
||
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)
|
||
if _points_deducted > 0 and _points_svc is not None:
|
||
try:
|
||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||
except Exception as refund_err:
|
||
logger.warning(f"对口型异常退积分异常: err={refund_err}")
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"创建对口型任务失败: {exc}",
|
||
) from exc
|
||
|
||
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
|
||
# 若任务已创建且状态为 failed,退费
|
||
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
|
||
try:
|
||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||
except Exception as refund_err:
|
||
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
|
||
|
||
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),
|
||
):
|
||
user_id = current_user.user.id
|
||
|
||
# ── 积分扣点(#1895 P2) ──
|
||
_points_deducted = 0
|
||
_points_scene = "ai_digital_human"
|
||
_points_svc = PointsService() if settings.points_enabled else None
|
||
if _points_svc is not None:
|
||
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
|
||
_points_deducted = calculate_points_cost(
|
||
_points_scene,
|
||
is_member=getattr(current_user.user, "is_member", False),
|
||
duration_minutes=est_minutes,
|
||
member_type=getattr(current_user.user, "member_type", None),
|
||
)
|
||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||
if not _deduct_res["success"]:
|
||
raise HTTPException(
|
||
status_code=402,
|
||
detail={
|
||
"code": "INSUFFICIENT_POINTS",
|
||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||
"required": _points_deducted,
|
||
"balance": _deduct_res["balance"],
|
||
},
|
||
)
|
||
"""步骤1「生成配音」同步 TTS 预合成.
|
||
|
||
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
|
||
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
|
||
耗时约 2-3 秒。
|
||
"""
|
||
try:
|
||
result = svc.preview_tts(
|
||
user_id=user_id,
|
||
voice_id=body.voice_id,
|
||
script_text=body.script_text,
|
||
speed=body.speed,
|
||
emotion=body.emotion,
|
||
)
|
||
except MediaKitError as exc:
|
||
if _points_deducted > 0 and _points_svc is not None:
|
||
try:
|
||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||
except Exception as refund_err:
|
||
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
|
||
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)
|
||
if _points_deducted > 0 and _points_svc is not None:
|
||
try:
|
||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||
except Exception as refund_err:
|
||
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
|
||
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 状态可取消)."""
|
||
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
|