diff --git a/alembic/versions/071_add_lipsync_jobs_table.py b/alembic/versions/071_add_lipsync_jobs_table.py new file mode 100644 index 000000000..0ff2ddb5d --- /dev/null +++ b/alembic/versions/071_add_lipsync_jobs_table.py @@ -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") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index f61148af4..7fe42611e 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -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"], +) diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py new file mode 100644 index 000000000..9e478bd43 --- /dev/null +++ b/apps/api/app/api/routes/lipsync.py @@ -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 diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py new file mode 100644 index 000000000..2429b6977 --- /dev/null +++ b/apps/api/app/schemas/lipsync.py @@ -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="驱动音频 URL(mp3/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 diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py new file mode 100644 index 000000000..3f7dd142e --- /dev/null +++ b/apps/api/app/services/lipsync_service.py @@ -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 diff --git a/apps/api/app/services/mediakit_client.py b/apps/api/app/services/mediakit_client.py new file mode 100644 index 000000000..fb70d60b6 --- /dev/null +++ b/apps/api/app/services/mediakit_client.py @@ -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: 人物视频 URL(MP4,≤30min,单人真人) + audio_url: 驱动音频 URL(mp3/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 diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index 408933adc..f4d17fabd 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -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 { diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index b242bc569..fdba0735f 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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)) diff --git a/tests/unit/test_lipsync_routes.py b/tests/unit/test_lipsync_routes.py new file mode 100644 index 000000000..b489bc028 --- /dev/null +++ b/tests/unit/test_lipsync_routes.py @@ -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" diff --git a/tests/unit/test_mediakit_client.py b/tests/unit/test_mediakit_client.py new file mode 100644 index 000000000..112bf9ea7 --- /dev/null +++ b/tests/unit/test_mediakit_client.py @@ -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")