feat: #1796 MediaKit 对口型后端对接 (#1801)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 17s
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 / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m26s
CI/CD Pipeline / Validate - Style (push) Successful in 2m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m47s
CI/CD Pipeline / Unit Tests (push) Successful in 8m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m43s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1801.
This commit is contained in:
2026-09-08 16:47:57 +08:00
committed by auto-approve-bot
parent 2cca03f680
commit f7825e3956
10 changed files with 1462 additions and 1 deletions
@@ -0,0 +1,47 @@
"""add lipsync jobs table
Revision ID: 071_add_lipsync_jobs
Revises: 070_add_scripts
Create Date: 2026-09-08
"""
import sqlalchemy as sa
from alembic import op
revision = "071_add_lipsync_jobs"
down_revision = "070_add_scripts"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"lipsync_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# 复合索引:用户 + 状态(列表查询常用)
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
# 项目 + 用户(项目维度查询)
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
def downgrade() -> None:
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
op.drop_table("lipsync_jobs")
+151
View File
@@ -15,6 +15,7 @@ from app.api.routes.generation_variant_plans import router as generation_variant
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.projects import router as projects_router
from app.api.routes.scripts import router as scripts_router
from app.api.routes.share import router as share_router
@@ -39,141 +40,291 @@ api_router.include_router(
auth_router,
tags=["Auth"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
projects_router,
prefix="/projects",
tags=["Project"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
tags_router,
prefix="/tags",
tags=["Tag"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
cover_templates_router,
tags=["CoverTemplate"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
task_center_router,
tags=["TaskCenter"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
asset_diagnosis_router,
tags=["AssetDiagnosis"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
asset_libraries_router,
prefix="/asset-libraries",
tags=["AssetLibrary"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
assets_router,
prefix="/assets",
tags=["Asset"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
ingest_jobs_router,
prefix="/ingest-jobs",
tags=["IngestJob"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
classification_jobs_router,
prefix="/classification-jobs",
tags=["ClassificationJob"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
upload_router,
prefix="/upload",
tags=["Upload"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
chunked_upload_router,
prefix="/upload/chunk",
tags=["ChunkedUpload"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
generation_tasks_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
generation_preview_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
generation_variant_plans_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
titles_router,
prefix="/titles",
tags=["TitleLibrary"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
voices_router,
prefix="/voices",
tags=["VoiceLibrary"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
voice_clones_router,
prefix="/voice-clones",
tags=["VoiceClone"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
videos_router,
tags=["VideoCenter"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
share_router,
tags=["Share"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
duplication_router,
prefix="/duplication",
tags=["Duplication"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
subscription_router,
prefix="/subscription",
tags=["Subscription"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
templates_router,
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
templates_editor_router,
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
tts_router,
prefix="/tts",
tags=["TTS"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
ai_router,
prefix="/ai",
tags=["AI"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
feature_flags_router,
tags=["Internal"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
internal_render_router,
tags=["Internal"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
scripts_router,
prefix="/scripts",
tags=["ScriptLibrary"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
+145
View File
@@ -0,0 +1,145 @@
"""对口型 API 路由 — #1796 MediaKit 对口型.
接口:
POST /api/v1/lipsync/jobs 提交对口型任务
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 取消任务
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
return LipsyncService(db)
# ── 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),
):
"""提交对口型任务.
输入人物视频 + 驱动音频,异步生成口型对齐视频。
"""
try:
job = svc.create_job(
user_id=current_user.id,
video_url=body.video_url,
audio_url=body.audio_url,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
)
except MediaKitError as exc:
# 创建失败(job 已记录 error),返回 502
raise HTTPException(
status_code=502,
detail={
"code": exc.code,
"message": str(exc),
"request_id": exc.request_id,
},
) from exc
return job
# ── 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.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,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务详情."""
job = svc.get_job(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
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.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/submitted 状态可取消)."""
job = svc.cancel_job(job_id, current_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/submitted 可取消",
)
return job
+70
View File
@@ -0,0 +1,70 @@
"""对口型 API Schema 定义 — #1796."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class LipsyncJobResponse(BaseModel):
"""对口型任务响应."""
id: str
user_id: str
project_id: str
video_url: str
audio_url: str
enable_video_loop: bool
mediakit_task_id: str
status: str
output_video_url: str
output_duration: float
error_message: str
error_code: str
submitted_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CreateLipsyncJobRequest(BaseModel):
"""创建对口型任务请求."""
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
audio_url: str = Field(..., description="驱动音频 URLmp3/aac/wav/m4a/flac")
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
project_id: str = Field("", description="项目 ID(可选)")
@field_validator("video_url")
@classmethod
def validate_video_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("video_url 不能为空")
if not v.startswith(("http://", "https://")):
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
# 仅支持 MP4
lower = v.lower().split("?")[0]
if not lower.endswith(".mp4"):
raise ValueError("video_url 仅支持 MP4 格式")
return v
@field_validator("audio_url")
@classmethod
def validate_audio_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("audio_url 不能为空")
if not v.startswith(("http://", "https://")):
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
# 支持的音频格式
lower = v.lower().split("?")[0]
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
if not any(lower.endswith(ext) for ext in allowed_exts):
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
return v
+181
View File
@@ -0,0 +1,181 @@
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
职责:
- 创建/查询/取消对口型任务
- 调用 MediaKit 客户端提交异步任务
- 轮询更新任务状态
- 用户隔离(每个用户只能操作自己的任务)
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from app.services.mediakit_client import (
STATUS_COMPLETED,
STATUS_FAILED,
STATUS_RUNNING,
MediaKitClient,
MediaKitError,
get_mediakit_client,
)
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
logger = logging.getLogger(__name__)
class LipsyncService:
"""对口型任务 Service."""
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
self.db = db
self.client = client or get_mediakit_client()
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
self,
*,
user_id: str,
video_url: str,
audio_url: str,
enable_video_loop: bool = False,
project_id: str = "",
) -> LipsyncJobModel:
"""创建对口型任务并提交到 MediaKit.
Raises:
MediaKitError: API 调用失败
"""
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
job = LipsyncJobModel(
id=job_id,
user_id=user_id,
project_id=project_id,
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
status="pending",
)
self.db.add(job)
self.db.flush()
# 2. 提交到 MediaKit
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
client_token=job_id, # 幂等控制
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
raise
self.db.commit()
self.db.refresh(job)
return job
# ── 查询任务 ──────────────────────────────────────────────────────────
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""获取任务详情(用户隔离)."""
return (
self.db.query(LipsyncJobModel)
.filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id)
.first()
)
def list_jobs(
self,
*,
user_id: str,
project_id: str = "",
status: str = "",
offset: int = 0,
limit: int = 20,
) -> tuple[list[LipsyncJobModel], int]:
"""获取任务列表(分页 + 用户隔离)."""
query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id)
if project_id:
query = query.filter(LipsyncJobModel.project_id == project_id)
if status:
query = query.filter(LipsyncJobModel.status == status)
total = query.count()
items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all()
return items, total
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""从 MediaKit 拉取最新状态并更新本地记录.
Returns:
更新后的 Job,或 None(任务不存在/不属于该用户)
"""
job = self.get_job(job_id, user_id)
if job is None:
return None
# 终态不需要再轮询
if job.status in (STATUS_COMPLETED, "failed"):
return job
# 未提交的任务不轮询
if not job.mediakit_task_id:
return job
try:
status_data = self.client.get_task_status(job.mediakit_task_id)
except MediaKitError as exc:
logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc)
return job
mk_status = status_data.get("status", STATUS_RUNNING)
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
job.output_video_url = result.get("video_url", "")
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
# running 状态只更新时间戳
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""取消任务(仅 pending/submitted 状态可取消)."""
job = self.get_job(job_id, user_id)
if job is None:
return None
if job.status in ("pending", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
+243
View File
@@ -0,0 +1,243 @@
"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API.
接口文档:https://docs.volcengine.com/docs/6448/2656064
异步任务流程:
1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id
2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed
3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效)
设计原则:
- API Key 从配置读取(settings.mediakit_api_key
- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程
- HTTP 超时/网络异常统一包装为 MediaKitError
"""
from __future__ import annotations
import logging
from typing import Any, Optional
import httpx
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
# ── 任务状态常量 ──────────────────────────────────────────────────────────
STATUS_RUNNING = "running"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
class MediaKitError(Exception):
"""MediaKit API 调用异常."""
def __init__(self, message: str, code: str = "", request_id: str = ""):
self.code = code
self.request_id = request_id
super().__init__(message)
class MediaKitClient:
"""火山引擎 AI MediaKit 对口型 API 客户端.
用法:
client = get_mediakit_client()
result = client.submit_lipsync(video_url="...", audio_url="...")
task_id = result["task_id"]
status = client.get_task_status(task_id)
# {"status": "completed", "result": {"video_url": "...", "duration": 60.5}}
"""
def __init__(self) -> None:
settings = get_api_settings()
self._api_key = settings.mediakit_api_key
self._base_url = settings.mediakit_base_url.rstrip("/")
self._timeout = settings.mediakit_timeout
@property
def is_available(self) -> bool:
"""是否已配置 API Key(未配置时自动降级)."""
return bool(self._api_key)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
# ── 提交对口型任务 ────────────────────────────────────────────────────
def submit_lipsync(
self,
*,
video_url: str,
audio_url: str,
enable_video_loop: bool = False,
callback_url: Optional[str] = None,
callback_args: Optional[str] = None,
client_token: Optional[str] = None,
) -> dict[str, Any]:
"""提交视频口型对齐任务.
Args:
video_url: 人物视频 URLMP4,≤30min,单人真人)
audio_url: 驱动音频 URLmp3/aac/wav/m4a/flac
enable_video_loop: 音频长于视频时是否循环画面
callback_url: 任务完成回调 URL
callback_args: 回调时原样返回的自定义参数
client_token: 幂等控制 token
Returns:
{"success": True, "task_id": "...", "request_id": "..."}
Raises:
MediaKitError: API 调用失败
"""
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
payload: dict[str, Any] = {
"video_url": video_url,
"audio_url": audio_url,
}
if enable_video_loop:
payload["enable_video_loop"] = True
if callback_url:
payload["callback_url"] = callback_url
if callback_args:
payload["callback_args"] = callback_args[:512] # API 限制 512 字节
if client_token:
payload["client_token"] = client_token[:64] # API 限制 64 字符
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(
f"{self._base_url}/tools/lip-sync",
headers=self._headers(),
json=payload,
)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as exc:
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
except httpx.HTTPStatusError as exc:
body = exc.response.text[:500]
raise MediaKitError(
f"MediaKit API HTTP {exc.response.status_code}: {body}",
code="HttpError",
) from exc
except httpx.RequestError as exc:
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
except Exception as exc:
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
if not data.get("success"):
error = data.get("error", {})
raise MediaKitError(
error.get("message", "提交任务失败"),
code=error.get("code", "SubmitFailed"),
request_id=data.get("request_id", ""),
)
return {
"success": True,
"task_id": data["task_id"],
"request_id": data.get("request_id", ""),
}
# ── 查询任务状态 ──────────────────────────────────────────────────────
def get_task_status(self, task_id: str) -> dict[str, Any]:
"""查询异步任务状态和结果.
Args:
task_id: 提交任务时返回的任务 ID
Returns:
{
"success": True,
"task_id": "...",
"status": "running" | "completed" | "failed",
"result": {"video_url": "...", "duration": 60.5} | None,
"error": {"code": "...", "message": "..."} | None,
"created_at": 1777291767,
"finished_at": 1777291851 | None,
"expires_at": 1777464650 | None,
}
Raises:
MediaKitError: API 调用失败
"""
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.get(
f"{self._base_url}/tasks/{task_id}",
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as exc:
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
except httpx.HTTPStatusError as exc:
body = exc.response.text[:500]
raise MediaKitError(
f"MediaKit API HTTP {exc.response.status_code}: {body}",
code="HttpError",
) from exc
except httpx.RequestError as exc:
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
except Exception as exc:
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
if not data.get("success"):
error = data.get("error", {})
raise MediaKitError(
error.get("message", "查询任务失败"),
code=error.get("code", "QueryFailed"),
request_id=data.get("request_id", ""),
)
result: dict[str, Any] = {
"success": True,
"task_id": data.get("task_id", task_id),
"status": data.get("status", STATUS_RUNNING),
"result": data.get("result"),
"created_at": data.get("created_at"),
"finished_at": data.get("finished_at"),
"expires_at": data.get("expires_at"),
}
# 失败时提取错误信息
if data.get("status") == STATUS_FAILED:
error_obj = data.get("error", {})
result["error"] = {
"code": error_obj.get("code", "TaskFailed"),
"message": error_obj.get("message", "任务执行失败"),
}
return result
# ── 单例 ──────────────────────────────────────────────────────────────────
_client: Optional[MediaKitClient] = None
def get_mediakit_client() -> MediaKitClient:
"""获取 MediaKit 客户端单例."""
global _client
if _client is None:
_client = MediaKitClient()
return _client
def reset_mediakit_client() -> None:
"""重置客户端(测试用)."""
global _client
_client = None
@@ -6455,7 +6455,9 @@
border-radius: 4px;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
transition:
border-color 0.15s,
transform 0.1s;
}
.ep-color-swatch:hover {
@@ -668,3 +668,37 @@ class ScriptModel(Base):
tags = Column(JSON, nullable=False, default=list)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class LipsyncJobModel(Base):
"""对口型任务 ORM 模型 — #1796 MediaKit 对口型.
记录用户提交的对口型任务,跟踪 MediaKit 异步任务状态。
"""
__tablename__ = "lipsync_jobs"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), nullable=False, default="", index=True)
# 输入参数
video_url = Column(Text, nullable=False)
audio_url = Column(Text, nullable=False)
enable_video_loop = Column(Boolean, nullable=False, default=False)
# MediaKit 任务状态
mediakit_task_id = Column(String(200), nullable=False, default="", index=True)
status = Column(
String(20), nullable=False, default="pending", index=True
) # pending → submitted → processing → completed → failed
output_video_url = Column(Text, nullable=False, default="")
output_duration = Column(Float, nullable=False, default=0.0)
error_message = Column(Text, nullable=False, default="")
error_code = Column(String(100), nullable=False, default="")
# 时间戳
submitted_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+310
View File
@@ -0,0 +1,310 @@
"""对口型 API 路由 + Service 单元测试 — #1796.
CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
@pytest.fixture
def mock_mediakit():
"""Mock MediaKit 客户端."""
client = MagicMock()
client.is_available = True
client.submit_lipsync.return_value = {
"success": True,
"task_id": "mk-task-123",
"request_id": "mk-req-456",
}
client.get_task_status.return_value = {
"success": True,
"task_id": "mk-task-123",
"status": "completed",
"result": {"video_url": "https://output.mp4", "duration": 30.0},
"created_at": 1777291767,
"finished_at": 1777291851,
"expires_at": 1777464650,
}
return client
def _make_mock_job(
job_id="job-1",
user_id="user-1",
status="submitted",
mediakit_task_id="mk-task-123",
output_video_url="",
output_duration=0.0,
error_message="",
error_code="",
):
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.video_url = "https://example.com/video.mp4"
m.audio_url = "https://example.com/audio.mp3"
m.enable_video_loop = False
m.mediakit_task_id = mediakit_task_id
m.status = status
m.output_video_url = output_video_url
m.output_duration = output_duration
m.error_message = error_message
m.error_code = error_code
m.submitted_at = None
m.completed_at = None
m.created_at = None
m.updated_at = None
return m
class TestSchemaValidation:
"""Schema 验证测试."""
def test_valid_video_url(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert req.video_url == "https://example.com/video.mp4"
def test_invalid_video_url_not_mp4(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="MP4"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mov",
audio_url="https://example.com/audio.mp3",
)
def test_invalid_video_url_empty(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="不能为空"):
CreateLipsyncJobRequest(
video_url=" ",
audio_url="https://example.com/audio.mp3",
)
def test_invalid_video_url_not_http(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="HTTP"):
CreateLipsyncJobRequest(
video_url="ftp://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_valid_audio_formats(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]:
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url=f"https://example.com/audio{ext}",
)
assert req.audio_url.endswith(ext)
def test_invalid_audio_format(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
with pytest.raises(ValueError, match="格式不支持"):
CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.ogg",
)
def test_enable_video_loop_default(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert req.enable_video_loop is False
def test_video_url_strip_query_params(self):
"""视频 URL 含查询参数时,扩展名检查应忽略 ? 后面的部分."""
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mp4?token=abc",
audio_url="https://example.com/audio.mp3?sign=xyz",
)
assert "?token=" in req.video_url
class TestLipsyncServiceUnit:
"""Service 层单元测试(纯 mock,不依赖数据库)."""
def test_create_job_success(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
# 模拟 db.add + db.flush 不报错
mock_db.add = MagicMock()
mock_db.flush = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
job = svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert job.status == "submitted"
assert job.mediakit_task_id == "mk-task-123"
mock_mediakit.submit_lipsync.assert_called_once()
def test_create_job_api_failure(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
with pytest.raises(MediaKitError, match="API 调用失败"):
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_get_job_delegates_to_db(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job()
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.get_job("job-1", "user-1")
assert result is mock_job
mock_db.query.assert_called_once()
def test_get_job_not_found(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.get_job("nonexistent", "user-1")
assert result is None
def test_refresh_job_completed(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="submitted")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.refresh_job_status("job-1", "user-1")
assert result.status == "completed"
assert result.output_video_url == "https://output.mp4"
assert result.output_duration == 30.0
def test_refresh_job_failed(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_mediakit.get_task_status.return_value = {
"success": True,
"task_id": "mk-task-123",
"status": "failed",
"error": {"code": "DownloadFailed", "message": "无法下载"},
"created_at": 1777291767,
"finished_at": 1777291851,
}
mock_job = _make_mock_job(status="submitted")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.refresh_job_status("job-1", "user-1")
assert result.status == "failed"
assert result.error_code == "DownloadFailed"
def test_refresh_job_already_completed(self, mock_mediakit):
"""已完成的任务不轮询."""
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="completed")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.refresh_job_status("job-1", "user-1")
# 不应调用 MediaKit
mock_mediakit.get_task_status.assert_not_called()
assert result.status == "completed"
def test_cancel_job_pending(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="pending")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
def test_cancel_job_completed_not_allowed(self, mock_mediakit):
from app.services.lipsync_service import LipsyncService
mock_job = _make_mock_job(status="completed")
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = LipsyncService(mock_db, client=mock_mediakit)
result = svc.cancel_job("job-1", "user-1")
# 已完成不可取消
assert result.status == "completed"
+278
View File
@@ -0,0 +1,278 @@
"""MediaKit 客户端单元测试 — #1796."""
import os
from unittest.mock import MagicMock, patch
import httpx
import pytest
# 确保测试环境有 JWT_SECRET_KEY
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
from app.services.mediakit_client import (
MediaKitClient,
MediaKitError,
get_mediakit_client,
reset_mediakit_client,
)
@pytest.fixture(autouse=True)
def _reset_client():
"""每个测试前后重置单例."""
reset_mediakit_client()
yield
reset_mediakit_client()
@pytest.fixture
def mock_settings():
with patch("app.services.mediakit_client.get_api_settings") as m:
settings = MagicMock()
settings.mediakit_api_key = "test-api-key"
settings.mediakit_base_url = "https://mediakit.cn-beijing.volces.com/api/v1"
settings.mediakit_timeout = 30
m.return_value = settings
yield settings
@pytest.fixture
def mock_settings_no_key():
with patch("app.services.mediakit_client.get_api_settings") as m:
settings = MagicMock()
settings.mediakit_api_key = ""
settings.mediakit_base_url = "https://mediakit.cn-beijing.volces.com/api/v1"
settings.mediakit_timeout = 30
m.return_value = settings
yield settings
class TestMediaKitClientInit:
"""客户端初始化测试."""
def test_is_available_with_key(self, mock_settings):
client = MediaKitClient()
assert client.is_available is True
def test_is_available_without_key(self, mock_settings_no_key):
client = MediaKitClient()
assert client.is_available is False
def test_get_client_singleton(self, mock_settings):
c1 = get_mediakit_client()
c2 = get_mediakit_client()
assert c1 is c2
class TestSubmitLipsync:
"""提交对口型任务测试."""
def test_submit_success(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "amk-tool-lip-sync-123",
"request_id": "req-456",
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert result["success"] is True
assert result["task_id"] == "amk-tool-lip-sync-123"
assert result["request_id"] == "req-456"
def test_submit_without_api_key(self, mock_settings_no_key):
client = MediaKitClient()
with pytest.raises(MediaKitError, match="未配置"):
client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_submit_api_error(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": False,
"task_id": "",
"request_id": "req-789",
"error": {
"code": "InvalidParameter",
"message": "must specify audio_url",
"param": "audio_url",
"type": "BadRequest",
},
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
with pytest.raises(MediaKitError) as exc_info:
client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
assert exc_info.value.code == "InvalidParameter"
assert "audio_url" in str(exc_info.value)
def test_submit_timeout(self, mock_settings):
client = MediaKitClient()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.side_effect = httpx.TimeoutException("timeout")
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
with pytest.raises(MediaKitError, match="超时"):
client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
)
def test_submit_with_all_params(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-1",
"request_id": "req-1",
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.post.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.submit_lipsync(
video_url="https://example.com/video.mp4",
audio_url="https://example.com/audio.mp3",
enable_video_loop=True,
callback_url="https://callback.example.com",
callback_args="my_args",
client_token="token-123",
)
assert result["success"] is True
# 验证请求参数
call_args = mock_client.post.call_args
payload = call_args.kwargs["json"]
assert payload["enable_video_loop"] is True
assert payload["callback_url"] == "https://callback.example.com"
assert payload["callback_args"] == "my_args"
assert payload["client_token"] == "token-123"
class TestGetTaskStatus:
"""查询任务状态测试."""
def test_get_status_running(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-123",
"status": "running",
"created_at": 1777291767,
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.get_task_status("task-123")
assert result["status"] == "running"
assert result["result"] is None
def test_get_status_completed(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-123",
"status": "completed",
"result": {"video_url": "https://output.mp4", "duration": 60.5},
"created_at": 1777291767,
"finished_at": 1777291851,
"expires_at": 1777464650,
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.get_task_status("task-123")
assert result["status"] == "completed"
assert result["result"]["video_url"] == "https://output.mp4"
assert result["result"]["duration"] == 60.5
def test_get_status_failed(self, mock_settings):
client = MediaKitClient()
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"task_id": "task-123",
"status": "failed",
"error": {"code": "DownloadFailed", "message": "无法下载视频"},
"created_at": 1777291767,
"finished_at": 1777291851,
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.return_value = mock_response
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
result = client.get_task_status("task-123")
assert result["status"] == "failed"
assert result["error"]["code"] == "DownloadFailed"
def test_get_status_without_api_key(self, mock_settings_no_key):
client = MediaKitClient()
with pytest.raises(MediaKitError, match="未配置"):
client.get_task_status("task-123")
def test_get_status_network_error(self, mock_settings):
client = MediaKitClient()
with patch("httpx.Client") as mock_http:
mock_client = MagicMock()
mock_client.get.side_effect = httpx.RequestError("connection refused")
mock_client.__enter__ = MagicMock(return_value=mock_client)
mock_client.__exit__ = MagicMock(return_value=False)
mock_http.return_value = mock_client
with pytest.raises(MediaKitError, match="网络错误"):
client.get_task_status("task-123")