"""对口型 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, style: str = "", volume: int = 50, ) -> 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, style=style, volume=volume, emotion=emotion, 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, style: str = "", volume: int = 50, 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, style=style or "", 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, style or "", volume, 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, style: str = "", volume: int = 50, 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/processing 状态可取消). 当 job 走 GPU 路径(mediakit_task_id 以 "gpu:" 开头)且状态为 processing 时, 同步将关联的 GpuLipsyncTask 标记为 cancelled,以便 Worker 心跳时检测到取消信号。 """ job = self.get_job(job_id, user_id) if job is None: return None if job.status in ("pending", "tts_processing", "submitted", "processing"): # GPU 路径:同步标记关联的 GPU 任务为 cancelled if job.status == "processing" and job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"): gpu_task_id = job.mediakit_task_id[4:] # 去掉 "gpu:" 前缀 try: from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel gpu_task = self.db.get(GpuLipsyncTaskModel, gpu_task_id) if gpu_task and gpu_task.status == "processing": gpu_task.status = "cancelled" gpu_task.error_msg = "用户取消" gpu_task.updated_at = datetime.now(UTC) gpu_task.finished_at = datetime.now(UTC) logger.info("GPU 任务 %s 已被用户取消(通过 job_id=%s)", gpu_task_id, job_id) except Exception as exc: logger.warning("标记 GPU 任务取消失败(不影响 job 取消): %s", exc) job.status = "cancelled" job.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(job) return job