5f128d175d
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 73h54m41s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 73h54m24s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 73h54m13s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 73h54m18s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 73h54m0s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 73h54m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 73h54m7s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 73h54m12s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 73h54m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 73h54m18s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 73h54m19s
1. 删除封面展示区重复叠加的标题div(成片帧已含标题,不再CSS叠字) 2. 标题字号按视频分辨率等比缩放,预览容器动态测量宽度计算scale 3. enable_video_loop默认改为true,防止TTS音频长于出镜视频时被截断
582 lines
23 KiB
Python
582 lines
23 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 datetime, timezone
|
||
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,
|
||
)
|
||
|
||
# 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, normalize_emotion
|
||
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
|
||
|
||
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=normalize_emotion(emotion),
|
||
)
|
||
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. 签名 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(timezone.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
|
||
|
||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||
|
||
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=normalize_emotion(emotion) if is_tts_mode else (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,
|
||
normalize_emotion(emotion),
|
||
)
|
||
)
|
||
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(timezone.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 = "natural",
|
||
) -> 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=normalize_emotion(emotion),
|
||
)
|
||
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
|
||
|
||
# 未提交的任务不轮询
|
||
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)
|
||
|
||
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(timezone.utc)
|
||
job.updated_at = datetime.now(timezone.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,
|
||
)
|
||
self.db.refresh(job)
|
||
return job
|
||
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)
|
||
else:
|
||
if isinstance(mk_status, str) and mk_status:
|
||
job.status = mk_status
|
||
job.updated_at = datetime.now(timezone.utc)
|
||
self.db.commit()
|
||
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(timezone.utc)
|
||
self.db.commit()
|
||
self.db.refresh(job)
|
||
|
||
return job
|