c954e334e6
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 1s
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 / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
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 / Validate - Python (mypy + alembic) (push) Successful in 2m48s
CI/CD Pipeline / Validate - Style (push) Successful in 2m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m29s
CI/CD Pipeline / Validate - Security (push) Successful in 6m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m30s
CI/CD Pipeline / Unit Tests (push) Successful in 10m41s
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>
229 lines
7.8 KiB
Python
229 lines
7.8 KiB
Python
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
|
||
|
||
职责:
|
||
- 创建/查询/取消对口型任务
|
||
- 调用 TTS 合成音频(#1809:前端不再传 audio_url)
|
||
- 调用 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
|
||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class LipsyncService:
|
||
"""对口型任务 Service."""
|
||
|
||
def __init__(
|
||
self,
|
||
db: Session,
|
||
client: Optional[MediaKitClient] = None,
|
||
cosyvoice_service: Optional[CosyVoiceService] = None,
|
||
):
|
||
self.db = db
|
||
self.client = client or get_mediakit_client()
|
||
self._cosyvoice_service = cosyvoice_service
|
||
|
||
@property
|
||
def cosyvoice_service(self) -> CosyVoiceService:
|
||
if self._cosyvoice_service is None:
|
||
from app.dependencies import get_cosyvoice_service
|
||
|
||
self._cosyvoice_service = get_cosyvoice_service()
|
||
return self._cosyvoice_service
|
||
|
||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||
|
||
def create_job(
|
||
self,
|
||
*,
|
||
user_id: str,
|
||
video_url: str,
|
||
voice_id: str,
|
||
script_text: str,
|
||
enable_video_loop: bool = False,
|
||
project_id: str = "",
|
||
) -> LipsyncJobModel:
|
||
"""创建对口型任务并提交到 MediaKit.
|
||
|
||
#1809: 内部调 TTS 合成音频,不再由前端传 audio_url。
|
||
|
||
Raises:
|
||
CosyVoiceError: TTS 合成失败
|
||
MediaKitError: API 调用失败
|
||
"""
|
||
# 1. 调 TTS 合成音频
|
||
try:
|
||
tts_result = self.cosyvoice_service.synthesize_speech(
|
||
text=script_text,
|
||
voice_id=voice_id,
|
||
)
|
||
audio_url = tts_result.audio_url
|
||
except CosyVoiceError as exc:
|
||
logger.error("TTS 合成失败: voice_id=%s, error=%s", voice_id, exc)
|
||
# 创建失败记录
|
||
job_id = str(uuid.uuid4())
|
||
job = LipsyncJobModel(
|
||
id=job_id,
|
||
user_id=user_id,
|
||
project_id=project_id,
|
||
video_url=video_url,
|
||
audio_url="",
|
||
enable_video_loop=enable_video_loop,
|
||
status="failed",
|
||
error_message=f"TTS 合成失败: {exc}",
|
||
error_code="TTSSynthesisFailed",
|
||
)
|
||
self.db.add(job)
|
||
self.db.commit()
|
||
self.db.refresh(job)
|
||
raise
|
||
|
||
# 2. 创建数据库记录
|
||
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()
|
||
|
||
# 3. 提交到 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
|