08de0d9946
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m16s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- Move GPU wait_for_result to Celery background task (lipsync_gpu_process_async)
POST /lipsync/jobs now returns <1s instead of blocking 200s+
- Rewrite musetalk_server.py: MuseTalk receives full audio directly
(v2 architecture) — no pre-looping video before inference
Output video length = audio length, mux is fast stream copy
- Frontend polls GET /lipsync/jobs/{id} for status updates
- refresh_job_status: GPU async path (processing + no mediakit_task_id)
skips MediaKit polling; stale jobs (>30min) auto-marked failed
- 21 unit tests pass (11 GPU integration + 10 musetalk audio mux)
Co-Authored-By: Coze <coze-opensource@bytedance.com>
787 lines
33 KiB
Python
787 lines
33 KiB
Python
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整, #1845 配音前置.
|
||
|
||
职责:
|
||
- 创建/查询对口型任务
|
||
- 三输入模式:
|
||
1. TTS 直生(voice_id + script_text)→ 走 Celery 异步(降级路径)
|
||
2. 直接音频(audio_url,前端未传 timings)→ 同步下载 + 算 timings + 提交 MediaKit
|
||
3. 预合成音频(audio_url + sentence_timings,#1845 新主路径)→ 同步 ffprobe 校验时长 +
|
||
写入前端传来的 timings → 直接提交 MediaKit(~2-3s)
|
||
- 调用 MediaKit 客户端提交异步任务
|
||
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
|
||
- 用户隔离(每个用户只能操作自己的任务)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
import uuid
|
||
from datetime import UTC, datetime
|
||
from typing import Optional
|
||
from urllib.parse import urlparse
|
||
|
||
from app.services.mediakit_client import (
|
||
STATUS_COMPLETED,
|
||
STATUS_FAILED,
|
||
STATUS_RUNNING,
|
||
MediaKitClient,
|
||
MediaKitError,
|
||
get_mediakit_client,
|
||
)
|
||
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
|
||
|
||
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
|
||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||
from packages.application.cosyvoice_service import CosyVoiceError
|
||
from packages.config import get_api_settings
|
||
from packages.domain.sentence_timings import (
|
||
compute_sentence_timings,
|
||
probe_audio_duration,
|
||
)
|
||
from packages.shared.storage import get_shared_storage_service
|
||
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。
|
||
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||
|
||
|
||
class LipsyncService:
|
||
"""对口型任务 Service."""
|
||
|
||
def __init__(
|
||
self,
|
||
db: Session,
|
||
client: Optional[MediaKitClient] = None,
|
||
cosyvoice_service=None,
|
||
voice_clone_repo=None,
|
||
):
|
||
self.db = db
|
||
self.client = client or get_mediakit_client()
|
||
self._cosyvoice = cosyvoice_service
|
||
self._voice_clone_repo = voice_clone_repo
|
||
self.settings = get_api_settings()
|
||
|
||
def _get_cosyvoice(self):
|
||
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
|
||
if self._cosyvoice is None:
|
||
from app.dependencies import get_cosyvoice_service
|
||
|
||
self._cosyvoice = get_cosyvoice_service()
|
||
return self._cosyvoice
|
||
|
||
def _resolve_voice_id(self, voice_id: str, user_id: str) -> str:
|
||
"""将克隆音色 profile UUID 解析为 CosyVoice voice_id。
|
||
|
||
与 /tts/synthesize 保持一致:命中 profile → 校验归属 → 返回其 voice_id;
|
||
未命中(预置音色 ID 或克隆 CosyVoice voice_id)原样返回。
|
||
"""
|
||
if not voice_id:
|
||
return ""
|
||
if self._voice_clone_repo is None:
|
||
try:
|
||
from app.dependencies import get_voice_clone_profile_repository
|
||
|
||
self._voice_clone_repo = get_voice_clone_profile_repository(self.db)
|
||
except Exception:
|
||
return voice_id
|
||
try:
|
||
profile = self._voice_clone_repo.get(voice_id)
|
||
except Exception:
|
||
return voice_id
|
||
if profile is None:
|
||
return voice_id
|
||
if getattr(profile, "user_id", "") != user_id:
|
||
raise MediaKitError("无权访问该音色", code="VoiceForbidden")
|
||
if not getattr(profile, "voice_id", ""):
|
||
raise MediaKitError("音色克隆尚未完成,请稍后再试", code="VoiceNotReady")
|
||
return profile.voice_id
|
||
|
||
def _synthesize_and_persist_audio(
|
||
self,
|
||
*,
|
||
user_id: str,
|
||
job_id: str,
|
||
voice_id: str,
|
||
script_text: str,
|
||
speed: float,
|
||
emotion: str,
|
||
) -> str:
|
||
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
|
||
|
||
Raises:
|
||
MediaKitError: 合成失败
|
||
"""
|
||
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
|
||
cosyvoice = self._get_cosyvoice()
|
||
try:
|
||
result = cosyvoice.submit_synthesize_task(
|
||
text=script_text,
|
||
voice_id=actual_voice_id,
|
||
speed=speed,
|
||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||
language="zh",
|
||
)
|
||
except CosyVoiceError as exc:
|
||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||
except ValueError as exc:
|
||
raise MediaKitError(f"TTS 参数错误: {exc}", code="TTSInvalidParam") from exc
|
||
|
||
temp_url = result.get("audio_url", "")
|
||
if not temp_url:
|
||
raise MediaKitError("TTS 未返回音频 URL", code="TTSNoAudio")
|
||
|
||
# 转存到自家 OSS,避免临时 URL 过期导致 MediaKit 拉取失败
|
||
try:
|
||
audio_data = safe_download_bytes(
|
||
temp_url,
|
||
purpose="lipsync_tts_audio",
|
||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||
timeout=60.0,
|
||
)
|
||
storage = get_shared_storage_service()
|
||
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
|
||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||
logger.info("对口型 TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||
return permanent_url
|
||
except Exception as exc:
|
||
logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
|
||
return temp_url
|
||
|
||
def _submit_audio_direct(
|
||
self,
|
||
*,
|
||
job: LipsyncJobModel,
|
||
supplied_timings: Optional[list] = None,
|
||
supplied_duration: Optional[float] = None,
|
||
) -> None:
|
||
"""音频直传模式(包含 #1845 预合成路径):同步下载 → ffprobe → timings → 提交 MediaKit.
|
||
|
||
直接在 HTTP 请求内完成,不走 Celery。job.status 成功后置为 submitted。
|
||
失败时把 job 标成 failed 并 commit,然后抛 MediaKitError。
|
||
|
||
Args:
|
||
job: 已 commit 的 LipsyncJobModel(audio_url / video_url 已写入)
|
||
supplied_timings: 前端传来的预合成 timings(可选,可信时直接用)
|
||
supplied_duration: 前端传来的预合成时长(可选,用于优先避免重复探测)
|
||
"""
|
||
# 1. 下载音频
|
||
audio_data: bytes | None = None
|
||
try:
|
||
audio_data = safe_download_bytes(
|
||
job.audio_url,
|
||
purpose="lipsync_direct_audio",
|
||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||
timeout=60.0,
|
||
)
|
||
logger.info(
|
||
"[lipsync] 直传音频下载完成: job_id=%s size=%d",
|
||
job.id,
|
||
len(audio_data) if audio_data else 0,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("[lipsync] 直传音频下载失败,跳过 timings 计算: job_id=%s err=%s", job.id, exc)
|
||
|
||
# 2. ffprobe 探测时长(优先用前端传入的预合成时长,但以 ffprobe 为准做兜底校验)
|
||
audio_duration = 0.0
|
||
if audio_data:
|
||
audio_duration = probe_audio_duration(audio_data)
|
||
if audio_duration <= 0 and supplied_duration and supplied_duration > 0:
|
||
audio_duration = supplied_duration
|
||
logger.info(
|
||
"[lipsync] ffprobe 失败,使用前端传入的预合成时长: job_id=%s duration=%.2f", job.id, audio_duration
|
||
)
|
||
|
||
# 3. 句子时间戳:优先用前端预合成传入的 timings(后端预合成接口已经算过,可信);
|
||
# 否则若音频下载成功则重算;否则不设置(不阻塞主流程)
|
||
timings: Optional[list] = None
|
||
if supplied_timings:
|
||
timings = supplied_timings
|
||
logger.info("[lipsync] 使用前端预合成句子时间戳: job_id=%s sentences=%d", job.id, len(timings))
|
||
elif audio_data and audio_duration > 0 and job.script_text:
|
||
try:
|
||
timings = compute_sentence_timings(audio_data, job.script_text, audio_duration)
|
||
logger.info(
|
||
"[lipsync] 后端重算句子时间戳: job_id=%s sentences=%d duration=%.2f",
|
||
job.id,
|
||
len(timings) if timings else 0,
|
||
audio_duration,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("[lipsync] 句子时间戳计算失败(不阻塞): job_id=%s err=%s", job.id, exc)
|
||
|
||
if timings:
|
||
job.sentence_timings = timings
|
||
|
||
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
|
||
use_gpu = False
|
||
if self.settings.use_gpu_lipsync:
|
||
try:
|
||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||
|
||
gpu_svc = GpuLipsyncService(self.db)
|
||
if gpu_svc.has_available_worker():
|
||
use_gpu = True
|
||
logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id)
|
||
else:
|
||
logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id)
|
||
except Exception as exc:
|
||
logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||
|
||
if use_gpu:
|
||
try:
|
||
gpu_task = self._submit_to_gpu_create(job=job, gpu_svc=gpu_svc)
|
||
if gpu_task is not None:
|
||
# GPU 任务已创建,设为 processing 并异步等待结果
|
||
job.mediakit_task_id = f"gpu:{gpu_task.id}"
|
||
job.status = "processing"
|
||
job.updated_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
# 派发 Celery 异步任务处理 GPU 等待+结果回写
|
||
try:
|
||
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
|
||
logger.info(
|
||
"[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s",
|
||
job.id,
|
||
gpu_task.id,
|
||
)
|
||
except Exception as celery_exc:
|
||
logger.warning(
|
||
"[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s",
|
||
job.id,
|
||
celery_exc,
|
||
)
|
||
self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task)
|
||
return
|
||
# create 失败 → 回退 MediaKit
|
||
logger.warning("[lipsync] GPU 任务创建失败,回退 MediaKit: job_id=%s", job.id)
|
||
self.db.rollback()
|
||
except Exception as exc:
|
||
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||
try:
|
||
self.db.rollback()
|
||
except Exception:
|
||
pass
|
||
|
||
# 5. 签名 URL 并提交 MediaKit(兜底路径)
|
||
video_url = self._sign_media_url(job.video_url)
|
||
signed_audio_url = self._sign_media_url(job.audio_url)
|
||
job.audio_url = signed_audio_url
|
||
|
||
try:
|
||
result = self.client.submit_lipsync(
|
||
video_url=video_url,
|
||
audio_url=signed_audio_url,
|
||
enable_video_loop=job.enable_video_loop,
|
||
client_token=job.id,
|
||
)
|
||
job.mediakit_task_id = result["task_id"]
|
||
job.status = "submitted"
|
||
job.submitted_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
logger.info(
|
||
"[lipsync] 直传音频已提交 MediaKit: job_id=%s task_id=%s",
|
||
job.id,
|
||
result["task_id"],
|
||
)
|
||
except MediaKitError as exc:
|
||
job.status = "failed"
|
||
job.error_message = str(exc)
|
||
job.error_code = exc.code
|
||
logger.error("[lipsync] 直传音频提交 MediaKit 失败: job_id=%s err=%s", job.id, exc)
|
||
self.db.commit()
|
||
raise
|
||
|
||
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
|
||
|
||
def _is_own_oss_url(self, url: str, storage) -> bool:
|
||
"""判断 URL / 存储 key 是否属于自家 OSS。
|
||
|
||
- 裸存储 key(无 scheme):自家对象
|
||
- host 与 storage.public_url host 一致:自家对象
|
||
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
|
||
"""
|
||
if not url:
|
||
return False
|
||
parsed = urlparse(url)
|
||
if not parsed.scheme:
|
||
return True # 裸存储 key
|
||
public_base = getattr(storage, "public_url", "")
|
||
own_host = urlparse(public_base).netloc.lower() if public_base else ""
|
||
return bool(own_host) and parsed.netloc.lower() == own_host
|
||
|
||
def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]:
|
||
"""GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。
|
||
|
||
Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址
|
||
可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。
|
||
已是自家 OSS 对象(含裸 key)直接返回 None(无需转存);
|
||
转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败,
|
||
服务端重试耗尽后回退 MediaKit,不阻断业务)。
|
||
"""
|
||
if self._is_own_oss_url(job.audio_url, storage):
|
||
return None
|
||
try:
|
||
audio_data = safe_download_bytes(
|
||
job.audio_url,
|
||
purpose="lipsync_gpu_tts_audio",
|
||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||
timeout=60.0,
|
||
)
|
||
storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3"
|
||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||
logger.info(
|
||
"[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s",
|
||
job.id,
|
||
storage_key,
|
||
)
|
||
return permanent_url
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s",
|
||
job.id,
|
||
exc,
|
||
)
|
||
return None
|
||
|
||
def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]:
|
||
"""创建 GPU 任务并立即返回(异步模式)。
|
||
|
||
成功返回 gpu_task 对象;创建失败返回 None。
|
||
不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。
|
||
"""
|
||
storage = get_shared_storage_service()
|
||
persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage)
|
||
audio_url_for_task = persisted_audio_url or job.audio_url
|
||
gpu_task = gpu_svc.create_task(
|
||
video_url=job.video_url,
|
||
audio_url=audio_url_for_task,
|
||
lipsync_job_id=job.id,
|
||
user_id=job.user_id,
|
||
project_id=job.project_id,
|
||
)
|
||
logger.info(
|
||
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
|
||
job.id,
|
||
gpu_task.id,
|
||
)
|
||
return gpu_task
|
||
|
||
def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None:
|
||
"""同步等待 GPU 结果(Celery 派发失败时的降级路径)。"""
|
||
final_task = gpu_svc.wait_for_result(gpu_task.id)
|
||
if final_task is None:
|
||
logger.warning("[lipsync] GPU 同步等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
|
||
return
|
||
if final_task.status != "done":
|
||
logger.warning(
|
||
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
|
||
gpu_task.id,
|
||
final_task.status,
|
||
)
|
||
return
|
||
try:
|
||
storage = get_shared_storage_service()
|
||
signed_result_url = storage.get_download_url(
|
||
final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS
|
||
)
|
||
if signed_result_url:
|
||
final_task.result_url = signed_result_url
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"[lipsync] GPU 结果签名失败: gpu_task=%s err=%s",
|
||
gpu_task.id,
|
||
exc,
|
||
)
|
||
job.mediakit_task_id = ""
|
||
job.status = STATUS_COMPLETED
|
||
job.output_video_url = final_task.result_url
|
||
job.output_duration = final_task.result_duration or 0.0
|
||
job.completed_at = datetime.now(UTC)
|
||
job.updated_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
logger.info(
|
||
"[lipsync] GPU 同步等待完成: job_id=%s duration=%.2f",
|
||
job.id,
|
||
job.output_duration,
|
||
)
|
||
|
||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||
|
||
def create_job(
|
||
self,
|
||
*,
|
||
user_id: str,
|
||
video_url: str,
|
||
audio_url: str = "",
|
||
audio_duration: Optional[float] = None,
|
||
sentence_timings: Optional[list] = None,
|
||
voice_id: str = "",
|
||
script_text: str = "",
|
||
speed: float = 1.0,
|
||
emotion: str = "",
|
||
enable_video_loop: bool = True,
|
||
project_id: str = "",
|
||
) -> LipsyncJobModel:
|
||
"""创建对口型任务.
|
||
|
||
三种输入模式:
|
||
- TTS 直生:voice_id + script_text(audio_url 留空)
|
||
→ 创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务(降级路径)。
|
||
API 响应 <1s。
|
||
- 直接音频:audio_url 非空 + 无 sentence_timings
|
||
→ 同步下载音频 + 重算 timings + 提交 MediaKit(几秒完成)。
|
||
- 预合成音频(#1845 新主路径):audio_url 非空 + 传 sentence_timings
|
||
→ 同步 ffprobe 校验时长 + 写入 timings + 提交 MediaKit(~2-3s)。
|
||
|
||
Raises:
|
||
MediaKitError: 参数校验失败或 MediaKit 提交失败
|
||
"""
|
||
# 0. 输入校验
|
||
is_pre_synth = bool(audio_url) and bool(sentence_timings)
|
||
bool(audio_url) and not is_pre_synth
|
||
is_tts_mode = not bool(audio_url)
|
||
|
||
if is_tts_mode:
|
||
if not (voice_id and script_text):
|
||
raise MediaKitError(
|
||
"必须提供 audio_url 或 voice_id+script_text",
|
||
code="InvalidInput",
|
||
)
|
||
# TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败
|
||
self._resolve_voice_id(voice_id, user_id)
|
||
elif is_pre_synth:
|
||
# 预合成模式:script_text 可空(因为 timings 已自带句子文本),但仍建议传
|
||
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
|
||
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
|
||
|
||
# 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,
|
||
voice_id=voice_id or "",
|
||
script_text=script_text or "",
|
||
speed=speed,
|
||
emotion=emotion or "",
|
||
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
|
||
status="tts_processing" if is_tts_mode else "pending",
|
||
)
|
||
self.db.add(job)
|
||
self.db.flush()
|
||
|
||
# ⚠️ 必须先 commit 再发 Celery 任务 / 后续同步操作,避免事务竞态
|
||
self.db.commit()
|
||
self.db.refresh(job)
|
||
|
||
if is_tts_mode:
|
||
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交(降级路径)
|
||
try:
|
||
tts_synthesize_and_submit.apply_async(
|
||
args=(
|
||
job_id,
|
||
user_id,
|
||
voice_id,
|
||
script_text,
|
||
speed,
|
||
emotion or "",
|
||
)
|
||
)
|
||
except Exception as exc:
|
||
logger.exception(
|
||
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
|
||
job_id,
|
||
exc,
|
||
)
|
||
job.status = "failed"
|
||
job.error_message = f"Celery 任务投递失败: {exc}"
|
||
job.error_code = "AsyncDispatchFailed"
|
||
job.updated_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
else:
|
||
# 2b/2c. 直接音频 / 预合成音频:同步路径
|
||
self._submit_audio_direct(
|
||
job=job,
|
||
supplied_timings=sentence_timings,
|
||
supplied_duration=audio_duration,
|
||
)
|
||
self.db.refresh(job)
|
||
|
||
return job
|
||
|
||
# ── TTS 预合成(#1845 步骤1「生成配音」同步接口使用) ──────────────────
|
||
|
||
def preview_tts(
|
||
self,
|
||
*,
|
||
user_id: str,
|
||
voice_id: str,
|
||
script_text: str,
|
||
speed: float = 1.0,
|
||
emotion: str = "neutral",
|
||
) -> dict:
|
||
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
|
||
|
||
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效期)。
|
||
耗时约 2-3 秒,由前端在步骤1点「生成配音」时同步等待。
|
||
|
||
Returns:
|
||
{"audio_url": str, "duration": float, "sentence_timings": list[dict]}
|
||
|
||
Raises:
|
||
MediaKitError: TTS 合成失败 / 下载失败 / ffprobe 失败
|
||
"""
|
||
# 1. 音色解析(校验克隆音色归属)
|
||
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
|
||
cosyvoice = self._get_cosyvoice()
|
||
|
||
# 2. TTS 合成(同步,~2-3s)
|
||
try:
|
||
result = cosyvoice.submit_synthesize_task(
|
||
text=script_text,
|
||
voice_id=actual_voice_id,
|
||
speed=speed,
|
||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||
language="zh",
|
||
)
|
||
except CosyVoiceError as exc:
|
||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||
except ValueError as exc:
|
||
raise MediaKitError(f"TTS 参数错误: {exc}", code="TTSInvalidParam") from exc
|
||
|
||
temp_url = result.get("audio_url", "")
|
||
if not temp_url:
|
||
raise MediaKitError("TTS 未返回音频 URL", code="TTSNoAudio")
|
||
|
||
# 3. 下载音频到内存(用于 ffprobe + 静音检测)
|
||
try:
|
||
audio_data = safe_download_bytes(
|
||
temp_url,
|
||
purpose="tts_preview_audio",
|
||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||
timeout=60.0,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("[tts-preview] TTS 音频下载失败,仍返回 audio_url: user_id=%s err=%s", user_id, exc)
|
||
return {
|
||
"audio_url": temp_url,
|
||
"duration": 0.0,
|
||
"sentence_timings": [],
|
||
}
|
||
|
||
# 4. ffprobe 时长
|
||
duration = probe_audio_duration(audio_data)
|
||
if duration <= 0:
|
||
logger.warning("[tts-preview] ffprobe 未返回有效时长,timings 留空: user_id=%s", user_id)
|
||
return {
|
||
"audio_url": temp_url,
|
||
"duration": 0.0,
|
||
"sentence_timings": [],
|
||
}
|
||
|
||
# 5. 句子时间戳
|
||
timings = compute_sentence_timings(audio_data, script_text, duration)
|
||
|
||
logger.info(
|
||
"[tts-preview] TTS 预合成完成: user_id=%s duration=%.2f sentences=%d",
|
||
user_id,
|
||
duration,
|
||
len(timings),
|
||
)
|
||
return {
|
||
"audio_url": temp_url,
|
||
"duration": round(duration, 2),
|
||
"sentence_timings": timings,
|
||
}
|
||
|
||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||
|
||
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 拉取最新状态并更新本地记录."""
|
||
job = self.get_job(job_id, user_id)
|
||
if job is None:
|
||
return None
|
||
|
||
# 终态不需要再轮询
|
||
if job.status in (STATUS_COMPLETED, "failed"):
|
||
return job
|
||
|
||
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
|
||
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
|
||
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
|
||
if job.status in ("processing", "gpu_processing"):
|
||
_now = datetime.now(UTC)
|
||
_upd = job.updated_at
|
||
if _upd is not None and _upd.tzinfo is None:
|
||
_upd = _upd.replace(tzinfo=UTC)
|
||
stale_minutes = 30
|
||
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
|
||
logger.warning(
|
||
"GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s",
|
||
stale_minutes,
|
||
job_id,
|
||
)
|
||
job.status = "failed"
|
||
job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)"
|
||
job.error_code = "GpuTimeout"
|
||
job.completed_at = _now
|
||
job.updated_at = _now
|
||
self.db.commit()
|
||
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)
|
||
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
|
||
|
||
try:
|
||
if mk_status == STATUS_COMPLETED:
|
||
result = status_data.get("result", {})
|
||
job.status = STATUS_COMPLETED
|
||
temp_url = result.get("video_url", "")
|
||
job.output_video_url = temp_url
|
||
job.output_duration = result.get("duration", 0.0)
|
||
job.completed_at = datetime.now(UTC)
|
||
job.updated_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
# 异步转存自家 OSS
|
||
try:
|
||
from app.tasks.lipsync_tts import persist_output_video_task
|
||
|
||
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
|
||
job_id,
|
||
exc,
|
||
)
|
||
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(UTC)
|
||
else:
|
||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||
if isinstance(mk_status, str) and mk_status:
|
||
job.status = mk_status
|
||
job.updated_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
|
||
logger.error(
|
||
"refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s",
|
||
job_id,
|
||
mk_status,
|
||
exc,
|
||
exc_info=True,
|
||
)
|
||
try:
|
||
self.db.rollback()
|
||
except Exception:
|
||
pass
|
||
# DB commit 失败不 raise,返回当前 job 对象让下次轮询再试
|
||
self.db.refresh(job)
|
||
return job
|
||
|
||
def _persist_output_video(self, temp_url: str, job_id: str, user_id: str) -> str:
|
||
"""将 MediaKit 输出的临时视频 URL 转存到自家 OSS. 失败时回退返回原始临时 URL."""
|
||
if not temp_url:
|
||
return ""
|
||
try:
|
||
import httpx
|
||
|
||
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
|
||
resp = client.get(temp_url)
|
||
resp.raise_for_status()
|
||
data = resp.content
|
||
storage = get_shared_storage_service()
|
||
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
|
||
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
|
||
logger.info("对口型输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||
return self._sign_media_url(permanent_url) or temp_url
|
||
except Exception as exc:
|
||
logger.warning("对口型输出视频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
|
||
return temp_url
|
||
|
||
def _sign_media_url(self, url: str) -> str:
|
||
"""对自家 OSS 私有桶 URL 重签长有效期预签名."""
|
||
if not url:
|
||
return url
|
||
try:
|
||
storage = get_shared_storage_service()
|
||
public_base = getattr(storage, "public_url", "")
|
||
if not isinstance(public_base, str) or not public_base:
|
||
return url
|
||
own_host = urlparse(public_base).netloc.lower()
|
||
host = urlparse(url).netloc.lower()
|
||
if not own_host or host != own_host:
|
||
return url # 外部临时链接原样透传
|
||
signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
|
||
return signed or url
|
||
except Exception as exc:
|
||
logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
|
||
return url
|
||
|
||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||
|
||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||
"""取消任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||
job = self.get_job(job_id, user_id)
|
||
if job is None:
|
||
return None
|
||
|
||
if job.status in ("pending", "tts_processing", "submitted"):
|
||
job.status = "cancelled"
|
||
job.updated_at = datetime.now(UTC)
|
||
self.db.commit()
|
||
self.db.refresh(job)
|
||
|
||
return job
|