diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index ce19b9701..d9e96d1b7 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -191,7 +191,6 @@ def retry_render_job( return AiAvatarRenderJobResponse.model_validate(job) - # ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)──────── @@ -220,7 +219,9 @@ def generate_avatar_smart_cover( except Exception as exc: logger.error( "智能封面生成异常: user=%s video_url=%s err=%s", - current_user.user.id, video_url[:80], exc, + current_user.user.id, + video_url[:80], + exc, exc_info=True, ) cover_url = "" @@ -233,3 +234,72 @@ def generate_avatar_smart_cover( ) logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120]) return SmartCoverResponse(cover_url=cover_url, status="completed") + + +# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)──────── + + +@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse) +def generate_render_smart_cover( + job_id: str, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS). + + - 必须等渲染任务 completed 后才可调用(否则返回 400) + - 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url + """ + from app.services.ai_avatar_render_service import AiAvatarRenderService + + svc = AiAvatarRenderService(db) + job = svc.get_render_job(job_id, current_user.user.id) + if job is None: + raise HTTPException(status_code=404, detail="渲染任务不存在") + if job.status != "completed": + raise HTTPException(status_code=400, detail="请先完成视频生成") + video_url = (job.output_video_url or "").strip() + if not video_url: + raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空") + + try: + # 成片已叠加标题,不传 title_config 避免双重叠加 + cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5) + except Exception as exc: + logger.error( + "渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s", + current_user.user.id, + job_id, + video_url[:80], + exc, + exc_info=True, + ) + cover_url = "" + + if not cover_url: + return SmartCoverResponse( + cover_url="", + status="fallback_failed", + message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试", + ) + + # 更新 render_job 的封面字段(异步写入 DB;失败不影响返回) + try: + job.cover_config = { + **(job.cover_config if isinstance(job.cover_config, dict) else {}), + "mode": "auto_frame", + "url": cover_url, + } + job.output_cover_url = cover_url + job.updated_at = datetime.now(timezone.utc) + db.commit() + except Exception as exc: + logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc) + + logger.info( + "渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s", + current_user.user.id, + job_id, + cover_url[:120], + ) + return SmartCoverResponse(cover_url=cover_url, status="completed") diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index bd65653f4..a86d3bb8e 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -197,9 +197,8 @@ class AiAvatarRenderService: 1. 下载对口型输出视频 (20%) 2. 构建 FFmpeg 滤镜链 (40%) 3. 执行 FFmpeg 渲染 (80%) - 4. 提取封面 (90%) - 5. 上传到 OSS (95%) - 6. 更新任务状态 (100%) + 4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧 + 5. 更新任务状态 (100%) """ job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first() if job is None: @@ -311,64 +310,23 @@ class AiAvatarRenderService: job.progress = 80 self.db.commit() - # 4. 提取封面 (90%) - cover_path = "" - if job.cover_config: - cover_path = os.path.join(tmpdir, "cover.jpg") - cover_cmd = self._build_cover_extract_cmd( - cover_config=job.cover_config, - input_video=output_video_path, - output_path=cover_path, - ) - try: - cover_result = subprocess.run( - cover_cmd, - capture_output=True, - text=True, - timeout=60, - ) - if cover_result.returncode != 0: - logger.warning( - "封面提取失败(非致命),跳过: exit=%s stderr=%s", - cover_result.returncode, - (cover_result.stderr or "")[-300:], - ) - cover_path = "" - except Exception as cover_err: - logger.warning("封面提取异常(非致命),跳过: %s", cover_err) - cover_path = "" - - job.progress = 90 - self.db.commit() - - # 5. 上传到 OSS (95%) + # 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤); + # 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。 output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4") job.output_video_url = output_video_url - # 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加); - # MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path - smart_cover_url = "" - if output_video_url: - try: - from app.services.ai_avatar_cover_service import ( - generate_smart_cover, - ) - - smart_cover_url = generate_smart_cover( - output_video_url, - job_id=job_id, - max_frames=5, - # 注意:不传 title_config —— 最终输出视频已经通过 drawtext 叠加了标题, - # 再传会导致封面标题双重叠加 - ) - except Exception: - logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True) - - if smart_cover_url: - job.output_cover_url = smart_cover_url - elif cover_path: - output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg") - job.output_cover_url = output_cover_url + # 封面透传:如果用户已在 cover_config 中选定封面 URL(mode=upload 的自定义上传 或 + # mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。 + if isinstance(job.cover_config, dict): + _pre_cover_url = ( + job.cover_config.get("url") + or job.cover_config.get("imageUrl") + or job.cover_config.get("cover_url") + or "" + ) + if _pre_cover_url: + job.output_cover_url = _pre_cover_url + logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id) # 获取输出视频时长 job.output_duration = lipsync_job.output_duration @@ -535,41 +493,6 @@ class AiAvatarRenderService: ) return cmd - def _build_cover_extract_cmd( - self, - *, - cover_config: dict[str, Any], - input_video: str, - output_path: str, - ) -> list[str]: - """构建封面截帧 FFmpeg 命令(list 形式,shell=False).""" - if not cover_config or not isinstance(cover_config, dict): - timestamp = 0.0 - width = 0 - height = 0 - else: - timestamp = cover_config.get("timestamp", 0.0) - width = cover_config.get("width", 0) - height = cover_config.get("height", 0) - - cmd: list[str] = [ - "ffmpeg", - "-ss", - str(timestamp), - "-i", - input_video, - "-frames:v", - "1", - ] - if width > 0 and height > 0: - vf = ( - f"scale={width}:{height}:force_original_aspect_ratio=decrease," - f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2" - ) - cmd.extend(["-vf", vf]) - cmd.extend(["-y", output_path]) - return cmd - def _upload_to_oss(self, local_path: str, oss_key: str) -> str: """上传文件到 OSS,返回 URL. diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index d76d09ec4..442b2c970 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -313,11 +313,26 @@ class LipsyncService: if mk_status == STATUS_COMPLETED: result = status_data.get("result", {}) job.status = STATUS_COMPLETED - output_url = result.get("video_url", "") - # MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL) - job.output_video_url = self._persist_output_video(output_url, job_id, user_id) + temp_url = result.get("video_url", "") + # 先以临时 URL 立即返回前端(前端可立即播放),再异步 Celery 任务转存自家 OSS(步骤⑦) + 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(注意:必须在 commit 之后 dispatch,避免 commit 失败任务已发出) + 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" diff --git a/apps/api/app/tasks/lipsync_tts.py b/apps/api/app/tasks/lipsync_tts.py index b097d1beb..fb771f702 100644 --- a/apps/api/app/tasks/lipsync_tts.py +++ b/apps/api/app/tasks/lipsync_tts.py @@ -301,7 +301,9 @@ def tts_synthesize_and_submit( db.commit() return - # 2. 下载并转存到自家 OSS + # 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL) + audio_data: bytes | None = None + _st_tmp_path: str | None = None try: audio_data = safe_download_bytes( temp_url, @@ -316,28 +318,28 @@ def tts_synthesize_and_submit( }, timeout=60.0, ) - from packages.shared.storage import get_shared_storage_service - - 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("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key) - job.audio_url = permanent_url + logger.info( + "[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d", + job_id, + len(audio_data) if audio_data else 0, + ) except Exception as exc: + # 下载失败:audio_data 保持 None,2.5 静音检测会跳过;后续仍用 temp_url 提交 MediaKit logger.warning( - "[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", + "[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s", job_id, exc, ) - job.audio_url = temp_url + # TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥) + job.audio_url = temp_url + logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id) db.commit() # 2.5 计算精确句子时间戳(基于 TTS 音频静音检测) - # 直接复用步骤 2 已下载到内存的 audio_data,避免重新从 OSS 下载(私有桶未签名会失败) + # 直接复用步骤 2 已下载到内存的 audio_data,避免重新下载 import os as _os - _st_tmp_path = None try: import subprocess as _sp import tempfile as _tmpf @@ -448,3 +450,64 @@ def tts_synthesize_and_submit( logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id) finally: db.close() + + +@shared_task( + name="lipsync_tts.persist_output_video", + max_retries=2, + default_retry_delay=30, +) +def persist_output_video_task(job_id: str, user_id: str, temp_url: str): + """异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应). + + - MediaKit 返回 completed 后先以 temp_url 回前端(前端可立即播放临时 URL) + - Celery 后台下载 temp_url 并转存 OSS,成功后更新 job.output_video_url 为永久 URL + - 失败则保留 temp_url,不阻断主流程 + """ + + try: + from worker_app.db import SessionLocal # type: ignore + except Exception: # noqa: BLE001 + from app.db import SessionLocal # type: ignore + + from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel + from packages.shared.storage import get_shared_storage_service + + db = SessionLocal() + try: + job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first() + if job is None: + logger.error("[lipsync_tts.persist] Job not found: job_id=%s", job_id) + return + + if not temp_url: + logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id) + 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") + # 对自家 OSS URL 重签 7 天有效期预签名,供前端播放 + final_url = _sign_media_url(permanent_url) if permanent_url else temp_url + job.output_video_url = final_url + job.updated_at = datetime.now(timezone.utc) + db.commit() + logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key) + except Exception as exc: + logger.warning( + "[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s", + job_id, + exc, + ) + except Exception: + logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id) + finally: + db.close() diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 05fc5bff0..a13d0bd1c 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -23,9 +23,10 @@ import { getLipsyncJob, submitRender, getRenderJob, - generateSmartCover, + generateRenderSmartCover, } from "./api/aiAvatar" import { getOrCreateDefaultProject } from "@/api/projects" +import type { RenderJob } from "./types" import { normalizeEmotion, buildTitleConfigPayload, @@ -54,8 +55,6 @@ const AiAvatarPage: React.FC = () => { "generating", ) const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") - /* ── 智能封面加载态 ── */ - const [smartCoverLoading, setSmartCoverLoading] = useState(false) /* ── 渲染进度弹窗 ── */ const [showRenderModal, setShowRenderModal] = useState(false) const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">( @@ -63,6 +62,8 @@ const AiAvatarPage: React.FC = () => { ) const [renderProgress, setRenderProgress] = useState(0) const [renderErrorMessage, setRenderErrorMessage] = useState("") + /* ── 当前渲染任务对象(轮询更新;用于封面区判断渲染是否完成) ── */ + const [currentRenderJob, setCurrentRenderJob] = useState(null) /* ── 对口型轮询 ── */ const lipsyncTimerRef = useRef | null>(null) @@ -223,7 +224,12 @@ const AiAvatarPage: React.FC = () => { pip_scale: seg.pip_scale, })) as never, title_config: buildTitleConfigPayload(state.titleConfig), - cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url), + // 封面不阻塞渲染:用户未选定封面时传空 dict,后端不生成封面;渲染完成后再单独抽帧 + cover_config: + state.coverConfig.smart_cover_url || + (state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:")) + ? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url) + : {}, }) // 打开渲染进度弹窗,启动轮询 @@ -231,17 +237,20 @@ const AiAvatarPage: React.FC = () => { setRenderStatus("generating") setRenderProgress(job.progress ?? 0) setRenderErrorMessage("") + setCurrentRenderJob(job as RenderJob) if (renderTimerRef.current) clearInterval(renderTimerRef.current) renderTimerRef.current = setInterval(async () => { try { const updated = await getRenderJob(job.id) setRenderProgress(updated.progress ?? 0) + setCurrentRenderJob(updated) if (updated.status === "completed") { if (renderTimerRef.current) clearInterval(renderTimerRef.current) renderTimerRef.current = null setRenderStatus("completed") - // 渲染完成后,用最终视频的封面更新前端封面配置 + // 渲染完成后:如果后端已返回封面(用户预上传/预设)则同步到前端; + // 否则不自动设置封面,由用户在封面区点击"智能获取封面"主动抽帧(步骤③④) if (updated.output_cover_url) { state.setCoverConfig((prev) => ({ ...prev, @@ -282,38 +291,48 @@ const AiAvatarPage: React.FC = () => { setRenderErrorMessage("") }, []) - /* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */ - const handleSmartCover = useCallback(async () => { - // 基于对口型成片抽帧,必须先完成对口型 - const videoUrl = state.lipsyncJob?.output_video_url - if (state.lipsyncJob?.status !== "completed" || !videoUrl) { - message.warning("请先生成对口型视频,完成后再智能获取封面") - return - } - setSmartCoverLoading(true) - try { - const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5) - if (res.cover_url) { - state.setCoverConfig((prev) => ({ - ...prev, - mode: "auto_frame", - smart_cover_url: res.cover_url, - thumbnail_url: res.cover_url, - })) - message.success("智能封面已生成") - } else { - message.error(res.message || "智能封面生成失败,请稍后重试") + /* ── 智能封面:从最终渲染成片抽帧(POST /renders/{id}/smart-cover,步骤③④) ── */ + const handleGenerateRenderSmartCover = useCallback( + async (renderId: string): Promise<{ cover_url: string; message?: string }> => { + try { + const res = await generateRenderSmartCover(renderId) + if (res.cover_url) { + state.setCoverConfig((prev) => ({ + ...prev, + mode: "auto_frame", + smart_cover_url: res.cover_url, + thumbnail_url: res.cover_url, + })) + message.success("智能封面已生成") + return { cover_url: res.cover_url } + } + const errMsg = res.message || "智能封面生成失败,请稍后重试" + message.error(errMsg) + return { cover_url: "", message: errMsg } + } catch (err) { + console.error("智能封面生成失败:", err) + const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试" + message.error(errMsg) + return { cover_url: "", message: errMsg } } - } catch (err) { - console.error("智能封面生成失败:", err) - message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试") - } finally { - setSmartCoverLoading(false) - } + }, + // state.setCoverConfig 是 zustand action 引用稳定,eslint 不需要检查 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.lipsyncJob]) + [], + ) /* ── 配置汇总 ── */ + const coverStatus: "not_ready" | "pending" | "selected" = (() => { + if ( + state.coverConfig.smart_cover_url || + state.coverConfig.thumbnail_url || + (state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:")) + ) { + return "selected" + } + if (currentRenderJob?.status === "completed") return "pending" + return "not_ready" + })() const summary = { videoName: state.selectedVideo?.name || null, voiceName: state.selectedVoice?.name || null, @@ -321,7 +340,7 @@ const AiAvatarPage: React.FC = () => { lipsyncStatus: state.lipsyncJob?.status || null, brollCount: state.bRollSegments.length, hasTitle: state.titleConfig.title.length > 0, - hasCover: state.coverConfig.enabled, + coverStatus, } return ( @@ -457,9 +476,8 @@ const AiAvatarPage: React.FC = () => { state.setCoverConfig((prev) => ({ ...prev, ...partial })) } titleConfig={state.titleConfig} - onSmartCover={handleSmartCover} - smartCoverLoading={smartCoverLoading} - canSmartCover={state.lipsyncJob?.status === "completed"} + renderJob={currentRenderJob} + onGenerateRenderSmartCover={handleGenerateRenderSmartCover} resolution={state.resolution} onResolutionChange={state.setResolution} isGenerating={state.isGenerating} diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index 3af8d7fba..25ec6cf85 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -94,3 +94,16 @@ export const getRenderJob = async (jobId: string): Promise => { export const cancelRenderJob = async (jobId: string): Promise => { await apiClient.post(`/ai-avatar/render/${jobId}/cancel`) } + +/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */ +export const generateRenderSmartCover = async ( + jobId: string, +): Promise<{ cover_url: string; status: string; message: string }> => { + const response = await apiClient.post<{ cover_url: string; status: string; message: string }>( + `/ai-avatar/render/${jobId}/smart-cover`, + {}, + // 抽帧+评分+转存 OSS 链路较长,120s 超时 + { timeout: 120000 }, + ) + return response.data +} diff --git a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx index ee5720f00..438400553 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx @@ -1,14 +1,15 @@ /** - * AI数字人 — 面板5:封面 & 生成 - * - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览 - * - 分辨率选择(720p / 1080p / 4K) - * - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面) - * - 渐变紫色生成按钮 + * AI数字人 — 面板5:分辨率/配置摘要/生成按钮/封面 + * v3 调整(步骤③④): + * - 布局顺序:分辨率 → 配置摘要卡片 → 🔘「开始生成视频」按钮 → (渲染完成后)封面区域 + * - 渲染未完成时封面区域显示占位态,按钮 disabled + * - 「智能获取封面」从最终成片抽帧(调用 POST /renders/{id}/smart-cover),不再依赖 lipsync 状态 + * - 修复点 2 次 bug:内部维护 smartCoverLoading,不依赖外层异步 state 更新 * * 注意:v3 已删除"画面插入模式",本面板不包含该选项。 */ -import React, { useMemo, useRef } from "react" -import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types" +import React, { useMemo, useRef, useState } from "react" +import type { AiAvatarCoverConfig, AiAvatarTitleConfig, RenderJob } from "../types" interface PanelCoverAndGenerateProps { coverConfig: AiAvatarCoverConfig @@ -18,10 +19,12 @@ interface PanelCoverAndGenerateProps { onResolutionChange: (r: string) => void isGenerating: boolean onGenerate: () => void - /** 智能获取封面(MediaKit 选帧) */ - onSmartCover: () => void - smartCoverLoading: boolean - canSmartCover: boolean + /** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */ + renderJob: RenderJob | null + /** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */ + onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> + /** 自定义上传封面(选择本地文件后由父组件处理实际上传) */ + onUploadCover?: (file: File) => void /** 配置汇总信息 */ summary: { videoName: string | null @@ -30,7 +33,8 @@ interface PanelCoverAndGenerateProps { lipsyncStatus: string | null brollCount: number hasTitle: boolean - hasCover: boolean + /** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */ + coverStatus: "not_ready" | "pending" | "selected" } } @@ -66,12 +70,14 @@ const PanelCoverAndGenerate: React.FC = ({ onResolutionChange, isGenerating, onGenerate, - onSmartCover, - smartCoverLoading, - canSmartCover, + renderJob, + onGenerateRenderSmartCover, + onUploadCover, summary, }) => { const uploadInputRef = useRef(null) + // 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序) + const [smartCoverLoading, setSmartCoverLoading] = useState(false) /** 自定义上传封面 */ const handleUploadClick = () => { @@ -81,22 +87,44 @@ const PanelCoverAndGenerate: React.FC = ({ const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return - // 本地预览:生成 object URL(实际上传由父级/后端链路处理) - const url = URL.createObjectURL(file) - onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url }) - // 允许重复选择同一文件 + if (onUploadCover) { + onUploadCover(file) + } else { + // 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示) + const url = URL.createObjectURL(file) + onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url }) + } e.target.value = "" } - /** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822) */ - const handleSmartCover = () => { - onCoverConfigChange({ mode: "auto_frame" }) - onSmartCover() + /** 智能获取封面(从最终成片抽帧;必须等 render 完成) */ + const handleSmartCover = async () => { + if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return + setSmartCoverLoading(true) + try { + const res = await onGenerateRenderSmartCover(renderJob.id) + if (res.cover_url) { + onCoverConfigChange({ + mode: "auto_frame", + smart_cover_url: res.cover_url, + thumbnail_url: res.cover_url, + }) + } else { + // 失败由父组件 message 提示,这里不重复弹窗 + console.warn("[智能封面] 返回空 cover_url:", res.message) + } + } catch (err) { + console.error("[智能封面] 调用失败:", err) + } finally { + setSmartCoverLoading(false) + } } const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null - const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating + // 渲染已完成 → 封面区可用 + const isRenderCompleted = renderJob?.status === "completed" + const canSmartCover = isRenderCompleted && !smartCoverLoading /** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */ const coverUrl = @@ -120,7 +148,7 @@ const PanelCoverAndGenerate: React.FC = ({ wordBreak: "break-word", whiteSpace: "pre-wrap", color: titleConfig.color || "#ffffff", - fontSize: `${(titleConfig.size || 48) * 0.35}px`, // 预览容器缩放,与 PanelLipsyncPreview 对齐 + fontSize: `${(titleConfig.size || 48) * 0.35}px`, fontFamily: getFontFamily(titleConfig.font), fontWeight: titleConfig.bold ? "bold" : "normal", fontStyle: titleConfig.italic ? "italic" : "normal", @@ -128,7 +156,6 @@ const PanelCoverAndGenerate: React.FC = ({ pointerEvents: "none", } - // 位置 const pos = titleConfig.position || "bottom" if (pos === "top") { style.top = "40px" @@ -136,7 +163,6 @@ const PanelCoverAndGenerate: React.FC = ({ style.top = "50%" style.transform = "translate(-50%, -50%)" } else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) { - // pos_x/pos_y 是相对预览容器的百分比坐标 style.left = `${titleConfig.pos_x}%` style.top = `${titleConfig.pos_y}%` style.transform = "translate(-50%, -50%)" @@ -144,67 +170,35 @@ const PanelCoverAndGenerate: React.FC = ({ style.bottom = "40px" } - // 描边优先于阴影(二者互斥,与 drawtext 对齐) if (titleConfig.stroke) { - // 描边宽度按字号估算,保证视觉一致 const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18)) ;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)` style.textShadow = "none" } else if (titleConfig.shadow) { style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)" } else { - // 无描边无阴影时,不加额外效果(与后端 drawtext 对齐:无 stroke/shadow 则不加) style.textShadow = "none" } return style }, [titleConfig]) + /** 封面区占位文字 */ + const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面" + + /** 封面摘要状态文本 */ + const coverSummaryNode = (() => { + if (summary.coverStatus === "selected") { + return 已选择 + } + if (summary.coverStatus === "pending") { + return 待选择 + } + return 生成视频后可选 + })() + return (
- {/* 封面预览(竖屏 9:16) */} -
- {hasCoverImage ? ( - 封面预览 - ) : ( - 暂无封面 - )} - {/* 智能封面加载遮罩 */} - {smartCoverLoading &&
⏳ 智能选帧中…
} - {/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */} - {showTitleOverlay && ( - - )} -
- -
- - - -
- {/* 分辨率选择 */}
@@ -212,6 +206,7 @@ const PanelCoverAndGenerate: React.FC = ({ className="aa-select" value={resolution} onChange={(e) => onResolutionChange(e.target.value)} + disabled={isGenerating} > {RESOLUTION_OPTIONS.map((opt) => (
封面 - {summary.hasCover ? ( - 已开启 - ) : ( - 未配置 - )} + {coverSummaryNode}
@@ -294,6 +285,60 @@ const PanelCoverAndGenerate: React.FC = ({ 请先完成对口型生成 )} + {isGenerating && ( +
+ 视频生成中,请稍候… +
+ )} + + + {/* 封面区域(视频生成后才激活;步骤③④要求:按钮在封面上方,完成后再显示封面区) */} +
+
+ 封面 +
+ {/* 封面预览(竖屏 9:16) */} +
+ {hasCoverImage ? ( + 封面预览 + ) : ( + {coverPlaceholder} + )} + {smartCoverLoading &&
⏳ 智能选帧中…
} + {showTitleOverlay && ( + + )} +
+ +
+ + + +
) diff --git a/apps/web/src/pages/ai-avatar/utils/contract.ts b/apps/web/src/pages/ai-avatar/utils/contract.ts index f617262b9..19d4843db 100644 --- a/apps/web/src/pages/ai-avatar/utils/contract.ts +++ b/apps/web/src/pages/ai-avatar/utils/contract.ts @@ -67,9 +67,14 @@ export function buildCoverConfigPayload( // build_cover_extract_command 读取 timestamp(截帧秒数) timestamp: cfg.frame_time || 0, } - if (smartCoverUrl) payload.cover_url = smartCoverUrl + // 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url) + if (smartCoverUrl) { + payload.url = smartCoverUrl + payload.cover_url = smartCoverUrl + } // 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用 if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) { + payload.url = cfg.upload_url payload.upload_url = cfg.upload_url } return payload diff --git a/tests/unit/test_ai_avatar_render_routes.py b/tests/unit/test_ai_avatar_render_routes.py index f8b368c6b..ee17b5079 100644 --- a/tests/unit/test_ai_avatar_render_routes.py +++ b/tests/unit/test_ai_avatar_render_routes.py @@ -318,3 +318,120 @@ class TestBrollOverlayFilter: "/tmp/cover.jpg", ) assert "scale=" in cmd + + +def _make_mock_auth_user(user_id="user-1"): + """构造 AuthenticatedUser:current_user.user.id.""" + auth = MagicMock() + auth.user.id = user_id + return auth + + +class TestRenderSmartCoverRoute: + """POST /renders/{job_id}/smart-cover — 从成片智能抽封面(步骤②).""" + + def test_smart_cover_job_not_found_returns_404(self): + """渲染任务不存在 → 404.""" + from app.api.routes.ai_avatar_render import generate_render_smart_cover + from fastapi import HTTPException + + mock_service = MagicMock() + mock_service.get_render_job.return_value = None + mock_db = MagicMock() + mock_user = _make_mock_auth_user() + + # 函数内部 `from app.services.ai_avatar_render_service import AiAvatarRenderService` + with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service): + with pytest.raises(HTTPException) as exc_info: + generate_render_smart_cover(job_id="render-missing", current_user=mock_user, db=mock_db) + + assert exc_info.value.status_code == 404 + assert "不存在" in exc_info.value.detail + mock_service.get_render_job.assert_called_once_with("render-missing", "user-1") + + def test_smart_cover_job_not_completed_returns_400(self): + """任务未 completed(如 processing)→ 400.""" + from app.api.routes.ai_avatar_render import generate_render_smart_cover + from fastapi import HTTPException + + mock_service = MagicMock() + mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/video.mp4") + mock_service.get_render_job.return_value = mock_job + mock_db = MagicMock() + mock_user = _make_mock_auth_user() + + with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service): + with pytest.raises(HTTPException) as exc_info: + generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db) + + assert exc_info.value.status_code == 400 + assert "先完成视频生成" in exc_info.value.detail + + def test_smart_cover_empty_video_url_returns_400(self): + """已 completed 但 output_video_url 为空/空白 → 400.""" + from app.api.routes.ai_avatar_render import generate_render_smart_cover + from fastapi import HTTPException + + mock_service = MagicMock() + mock_job = _make_mock_render_job(status="completed", output_video_url=" ") + mock_service.get_render_job.return_value = mock_job + mock_db = MagicMock() + mock_user = _make_mock_auth_user() + + with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service): + with pytest.raises(HTTPException) as exc_info: + generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db) + + assert exc_info.value.status_code == 400 + assert "URL 为空" in exc_info.value.detail + + def test_smart_cover_success_updates_db_and_returns_url(self): + """抽帧成功 → 更新 job.cover_config / output_cover_url 并 commit,返回 completed.""" + from app.api.routes.ai_avatar_render import generate_render_smart_cover + + mock_service = MagicMock() + mock_job = _make_mock_render_job( + status="completed", + output_video_url="https://oss/final.mp4", + ) + mock_job.cover_config = {"mode": "manual"} + mock_service.get_render_job.return_value = mock_job + mock_db = MagicMock() + mock_user = _make_mock_auth_user() + + with ( + patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service), + patch( + "app.api.routes.ai_avatar_render.generate_smart_cover", return_value="https://oss/cover.jpg" + ) as mock_gen, + ): + result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db) + + mock_gen.assert_called_once_with("https://oss/final.mp4", job_id="render-1", max_frames=5) + assert result.status == "completed" + assert result.cover_url == "https://oss/cover.jpg" + assert mock_job.output_cover_url == "https://oss/cover.jpg" + assert mock_job.cover_config["mode"] == "auto_frame" + assert mock_job.cover_config["url"] == "https://oss/cover.jpg" + mock_db.commit.assert_called_once() + + def test_smart_cover_extract_failure_returns_fallback_failed(self): + """generate_smart_cover 抛异常 → fallback_failed,不抛错不写 DB.""" + from app.api.routes.ai_avatar_render import generate_render_smart_cover + + mock_service = MagicMock() + mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/final.mp4") + mock_service.get_render_job.return_value = mock_job + mock_db = MagicMock() + mock_user = _make_mock_auth_user() + + with ( + patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service), + patch("app.api.routes.ai_avatar_render.generate_smart_cover", side_effect=RuntimeError("mediakit down")), + ): + result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db) + + assert result.status == "fallback_failed" + assert result.cover_url == "" + # 失败时不写 cover_config / 不 commit + mock_db.commit.assert_not_called() diff --git a/tests/unit/test_ai_avatar_render_service.py b/tests/unit/test_ai_avatar_render_service.py index 99e28928b..d9ce7434f 100644 --- a/tests/unit/test_ai_avatar_render_service.py +++ b/tests/unit/test_ai_avatar_render_service.py @@ -628,3 +628,58 @@ class TestAiAvatarRenderService: err = AiAvatarRenderError("测试错误", code="TestCode") assert err.code == "TestCode" assert str(err) == "测试错误" + + +class TestAiAvatarRenderCoverPassthrough: + """execute_render 中封面透传逻辑(320~329 行):cover_config 含 url/imageUrl/cover_url 时直接透传到 output_cover_url.""" + + def _run_execute(self, mock_job, mock_lipsync_job): + """驱动 execute_render 跑到完成阶段的通用脚手架(mock IO 部分).""" + from app.services.ai_avatar_render_service import AiAvatarRenderService + + mock_db = _make_mock_db() + mock_filter = MagicMock() + # query.filter 返回同一个 filter 两次(render_job 查询、lipsync 查询) + mock_filter.first.side_effect = [mock_job, mock_lipsync_job] + mock_query = MagicMock() + mock_query.filter.return_value = mock_filter + mock_db.query.return_value = mock_query + + svc = AiAvatarRenderService(mock_db) + with ( + patch.object(svc, "_download_video", return_value="/tmp/video.mp4"), + patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"), + patch("subprocess.run") as mock_run, + patch("tempfile.TemporaryDirectory") as tmpdir_mock, + patch("app.services.ai_avatar_cover_service.generate_smart_cover", return_value=""), + patch("packages.domain.generated_video.GeneratedVideo.create", return_value=MagicMock()), + patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository" + ) as repo_cls, + ): + import subprocess as _sp + + mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + import tempfile as _tf + + tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir") + tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False) + repo_cls.return_value = MagicMock() + svc.execute_render(mock_job.id) + return mock_db, mock_job + + def test_cover_url_in_cover_config_passthrough_to_output_cover(self): + """cover_config.url 存在 → 透传到 output_cover_url.""" + mock_job = _make_mock_render_job(job_id="render-cov-1", status="pending") + mock_job.cover_config = {"mode": "upload", "url": "https://oss/user-cover.jpg"} + mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0) + _, job = self._run_execute(mock_job, mock_lipsync_job) + assert job.output_cover_url == "https://oss/user-cover.jpg" + + def test_cover_imageurl_fallback_also_passthrough(self): + """cover_config.imageUrl(老字段)存在 → 也透传到 output_cover_url.""" + mock_job = _make_mock_render_job(job_id="render-cov-2", status="pending") + mock_job.cover_config = {"mode": "upload", "imageUrl": "https://oss/user-cover2.jpg"} + mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0) + _, job = self._run_execute(mock_job, mock_lipsync_job) + assert job.output_cover_url == "https://oss/user-cover2.jpg" diff --git a/tests/unit/test_lipsync_tts.py b/tests/unit/test_lipsync_tts.py index ce6e6334e..53c5373be 100644 --- a/tests/unit/test_lipsync_tts.py +++ b/tests/unit/test_lipsync_tts.py @@ -387,3 +387,242 @@ class TestSignMediaUrl: assert result == "https://anything.example.com/a.mp3" fake_storage.get_download_url.assert_not_called() + + +class TestPersistOutputVideoTask: + """persist_output_video_task:下载 MediaKit 临时视频 → 上传自有 OSS → 更新 DB.""" + + def _make_persist_job(self, **kwargs): + job = MagicMock() + job.id = kwargs.get("job_id", "job-1") + job.user_id = kwargs.get("user_id", "user-1") + job.output_video_url = kwargs.get("output_video_url", "https://temp.mk/output.mp4") + job.updated_at = None + return job + + def _persist_patches(self, *, job, video_bytes=b"FAKEMP4", download_side_effect=None, upload_url=None): + """统一 patch:SessionLocal、httpx.Client、storage、_sign_media_url.""" + fake_app_db = ModuleType("app.db") + fake_worker_db = ModuleType("worker_app.db") + session, factory = _build_session(job) + fake_app_db.SessionLocal = factory + fake_worker_db.SessionLocal = factory + + # httpx.Client 上下文管理器 + fake_response = MagicMock() + fake_response.content = video_bytes + fake_response.raise_for_status = MagicMock() + fake_client = MagicMock() + fake_client.get.return_value = fake_response + fake_client_cm = MagicMock() + fake_client_cm.__enter__ = MagicMock(return_value=fake_client) + fake_client_cm.__exit__ = MagicMock(return_value=False) + FakeHttpxClient = MagicMock(return_value=fake_client_cm) + if download_side_effect is not None: + fake_client.get.side_effect = download_side_effect + + # storage + storage = MagicMock() + storage.public_url = "https://oss.example.com/" + storage.upload_file.return_value = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4" + + fake_httpx = ModuleType("httpx") + fake_httpx.Client = FakeHttpxClient + + patches = [ + patch.dict( + sys.modules, + {"app.db": fake_app_db, "worker_app.db": fake_worker_db, "httpx": fake_httpx}, + ), + patch("packages.shared.storage.get_shared_storage_service", return_value=storage), + patch("app.tasks.lipsync_tts._sign_media_url", side_effect=lambda url: url + "?signed" if url else url), + ] + return session, fake_client, storage, patches + + def test_success_download_upload_updates_db(self): + """正常路径:下载 temp_url → 上传 OSS → 签名 → 写回 DB commit.""" + from app.tasks.lipsync_tts import persist_output_video_task + + job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4") + session, fake_client, storage, patches = self._persist_patches( + job=job, video_bytes=b"VIDEODATA", upload_url="https://oss.example.com/lipsync-outputs/u1/j1.mp4" + ) + entered = [p.__enter__() for p in patches] + try: + persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4") + finally: + for p in reversed(patches): + p.__exit__(None, None, None) + + fake_client.get.assert_called_once_with("https://temp.mk/x.mp4") + storage.upload_file.assert_called_once() + # 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4 + key_arg = ( + storage.upload_file.call_args.args[1] + if storage.upload_file.call_args.args + else storage.upload_file.call_args.kwargs.get("key") + ) + # upload_file(data, key, content_type=...) + call_args = storage.upload_file.call_args.args + assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4" + # output_video_url 被替换为签名后的永久 URL + assert job.output_video_url == "https://oss.example.com/lipsync-outputs/u1/j1.mp4?signed" + assert job.updated_at is not None + session.commit.assert_called_once() + session.close.assert_called_once() + + def test_download_failure_keeps_temp_url_no_commit(self): + """下载失败(raise)→ 记录 warning、保留 temp_url、不抛异常.""" + from app.tasks.lipsync_tts import persist_output_video_task + + job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4") + session, fake_client, storage, patches = self._persist_patches( + job=job, download_side_effect=RuntimeError("network down") + ) + entered = [p.__enter__() for p in patches] + try: + persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4") + finally: + for p in reversed(patches): + p.__exit__(None, None, None) + + storage.upload_file.assert_not_called() + # output_video_url 保持原值(temp_url) + assert job.output_video_url == "https://temp.mk/x.mp4" + # 内层 except 不会 commit + # 注:若内部发生 commit 说明测试失败 + session.close.assert_called_once() + + def test_empty_temp_url_skips_persist(self): + """temp_url 为空 → 直接返回,不下载不上传.""" + from app.tasks.lipsync_tts import persist_output_video_task + + job = self._make_persist_job(output_video_url="") + session, fake_client, storage, patches = self._persist_patches(job=job) + entered = [p.__enter__() for p in patches] + try: + persist_output_video_task("job-1", "user-1", "") + finally: + for p in reversed(patches): + p.__exit__(None, None, None) + + fake_client.get.assert_not_called() + storage.upload_file.assert_not_called() + session.commit.assert_not_called() + session.close.assert_called_once() + + def test_job_not_found_returns_early(self): + """DB 中找不到 job → 直接返回,不抛错.""" + from app.tasks.lipsync_tts import persist_output_video_task + + session, fake_client, storage, patches = self._persist_patches(job=None) + entered = [p.__enter__() for p in patches] + try: + persist_output_video_task("missing", "user-1", "https://temp.mk/x.mp4") + finally: + for p in reversed(patches): + p.__exit__(None, None, None) + + fake_client.get.assert_not_called() + storage.upload_file.assert_not_called() + session.commit.assert_not_called() + session.close.assert_called_once() + + +class TestLipsyncServiceRefreshCompletedAsyncPersist: + """refresh_job_status 在 completed 分支异步转存的单元测试(补 0% 覆盖的 316~335 行).""" + + def test_refresh_completed_dispatches_persist_task(self): + """completed 分支:设置 temp_url → commit → dispatch persist_output_video_task.apply_async.""" + from app.services.lipsync_service import LipsyncService + + mock_job = MagicMock() + mock_job.id = "job-1" + mock_job.user_id = "user-1" + mock_job.mediakit_task_id = "mk-1" + mock_job.status = "submitted" + mock_job.output_video_url = "" + mock_job.output_duration = 0.0 + + 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 + + mock_client = MagicMock() + mock_client.get_task_status.return_value = { + "status": "completed", + "result": {"video_url": "https://temp.mk/out.mp4", "duration": 25.5}, + } + + fake_persist_task = MagicMock() + svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock()) + with patch.dict("sys.modules", {}): + # 直接 patch 懒 import 路径 + with patch("app.tasks.lipsync_tts.persist_output_video_task", fake_persist_task, create=False): + # 但懒 import 发生在函数内部 from app.tasks.lipsync_tts import persist_output_video_task + # 通过 patch sys.modules 的方式提供 + import sys as _sys + + fake_mod = MagicMock() + fake_mod.persist_output_video_task = fake_persist_task + _sys.modules["app.tasks.lipsync_tts"] = fake_mod + try: + result = svc.refresh_job_status("job-1", "user-1") + finally: + _sys.modules.pop("app.tasks.lipsync_tts", None) + + assert result.status == "completed" + assert result.output_video_url == "https://temp.mk/out.mp4" + assert result.output_duration == 25.5 + mock_db.commit.assert_called() + # 必须在 commit 之后 dispatch + fake_persist_task.apply_async.assert_called_once() + kwargs = fake_persist_task.apply_async.call_args.kwargs + assert kwargs["args"] == ("job-1", "user-1", "https://temp.mk/out.mp4") + + def test_refresh_completed_dispatch_exception_does_not_break_return(self): + """apply_async 抛异常(如 Celery 不可用)→ 捕获 warning,仍返回 completed job.""" + from app.services.lipsync_service import LipsyncService + + mock_job = MagicMock() + mock_job.id = "job-2" + mock_job.user_id = "user-1" + mock_job.mediakit_task_id = "mk-2" + mock_job.status = "submitted" + mock_job.output_video_url = "" + mock_job.output_duration = 0.0 + + 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 + + mock_client = MagicMock() + mock_client.get_task_status.return_value = { + "status": "completed", + "result": {"video_url": "https://temp.mk/out2.mp4", "duration": 10.0}, + } + + fake_persist_task = MagicMock() + fake_persist_task.apply_async.side_effect = ConnectionError("celery down") + + svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock()) + import sys as _sys + + fake_mod = MagicMock() + fake_mod.persist_output_video_task = fake_persist_task + _sys.modules["app.tasks.lipsync_tts"] = fake_mod + try: + result = svc.refresh_job_status("job-2", "user-1") + finally: + _sys.modules.pop("app.tasks.lipsync_tts", None) + + # 即便 dispatch 失败,主流程不受影响:仍然返回 completed + temp_url + assert result.status == "completed" + assert result.output_video_url == "https://temp.mk/out2.mp4" + fake_persist_task.apply_async.assert_called_once()