From 2a6da04685148ad493ccac4fa25cd77a52e852eb Mon Sep 17 00:00:00 2001 From: saas-backend Date: Thu, 10 Sep 2026 17:48:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-avatar):=20=E4=BF=AE=E5=A4=8D=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=B0=81=E9=9D=A2502=20-=20cover=5Fservice=E8=A7=86?= =?UTF-8?q?=E9=A2=91URL=E9=87=8D=E7=AD=BE+=E8=BD=AE=E8=AF=A2=E6=89=A9?= =?UTF-8?q?=E5=AE=B9+=E8=BF=9E=E6=8E=A5=E6=B1=A0+=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: smart-cover接口传入的video_url为upload_file()返回的裸public_url, OSS桶为私有桶,MediaKit GPU worker拉取时返回403 Forbidden。 修复内容: 1. cover_service新增_sign_video_url_for_mediakit(),对自家OSS URL重签7天有效期 2. 增大MediaKit抽帧轮询参数(poll_interval=3.0, max_poll_attempts=20, max_retries=1) 3. 帧下载改用httpx.Client连接池复用TCP连接,减少延迟 4. smart-cover路由增加try/except异常捕获,防止未捕获异常导致502 测试: 新增3个测试(URL重签/外部URL跳过/轮询参数验证),全部15个测试通过 Closes #1840 --- apps/api/app/api/routes/ai_avatar_render.py | 13 ++- .../app/services/ai_avatar_cover_service.py | 106 +++++++++++++----- .../test_ai_avatar_emotion_tts_lipsync.py | 89 +++++++++++++-- 3 files changed, 174 insertions(+), 34 deletions(-) diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index 02a381846..b1b8d1b9e 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -198,12 +198,21 @@ def generate_avatar_smart_cover( if not video_url.startswith(("http://", "https://")): raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL") - cover_url = generate_smart_cover(video_url, max_frames=body.max_frames) + try: + cover_url = generate_smart_cover(video_url, max_frames=body.max_frames) + except Exception as exc: + logger.error( + "智能封面生成异常: user=%s video_url=%s err=%s", + current_user.user.id, video_url[:80], exc, + exc_info=True, + ) + cover_url = "" + if not cover_url: return SmartCoverResponse( cover_url="", status="fallback_failed", message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试", ) - logger.info("智能封面生成成功: user=%s", current_user.user.id) + logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120]) return SmartCoverResponse(cover_url=cover_url, status="completed") diff --git a/apps/api/app/services/ai_avatar_cover_service.py b/apps/api/app/services/ai_avatar_cover_service.py index 6e92d7ded..c1b312432 100644 --- a/apps/api/app/services/ai_avatar_cover_service.py +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -15,9 +15,49 @@ import tempfile import uuid from pathlib import Path from typing import Optional +from urllib.parse import urlparse logger = logging.getLogger(__name__) +# MediaKit 抽帧轮询参数(与 MediaKit API timeout=60s 对齐) +COVER_POLL_INTERVAL = 3.0 +COVER_MAX_POLL_ATTEMPTS = 20 # 最多等 60 秒 + +# 帧图片下载超时(秒) +FRAME_DOWNLOAD_TIMEOUT = 20 +# 最佳帧下载超时(用于 persist) +BEST_FRAME_DOWNLOAD_TIMEOUT = 30 + +# 自家 OSS 私有桶 URL 重签有效期(供 MediaKit GPU worker 拉取) +MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600 + + +def _sign_video_url_for_mediakit(video_url: str) -> str: + """如果 video_url 是自家 OSS 私有桶 URL,重新签名为长有效期预签名 URL。 + + MediaKit GPU worker 需要能公网访问 video_url,裸 public_url 在私有桶下会 403。 + """ + if not video_url: + return video_url + try: + from packages.shared.storage import get_shared_storage_service + + storage = get_shared_storage_service() + public_base = getattr(storage, "public_url", "") + if not isinstance(public_base, str) or not public_base: + return video_url + own_host = urlparse(public_base).netloc.lower() + url_host = urlparse(video_url).netloc.lower() + if own_host and url_host == own_host: + # 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取 + signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS) + if signed: + logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)") + return signed + except Exception: + logger.warning("[数字人封面] video_url 重签失败,使用原始 URL", exc_info=True) + return video_url + def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: """从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL. @@ -31,6 +71,10 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: """ if not video_url: return "" + + # 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签) + video_url = _sign_video_url_for_mediakit(video_url) + try: from packages.shared.cover_frame_scorer import score_frames from packages.shared.mediakit_client import get_mediakit_client @@ -40,13 +84,21 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: logger.warning("[数字人封面] MediaKit 未配置,无法智能抽帧") return "" + logger.info( + "[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d", + video_url[:80], + max_frames, + COVER_POLL_INTERVAL, + COVER_MAX_POLL_ATTEMPTS, + ) + snapshots = mk.extract_frames( video_url=video_url, strategy="SpecifiedFrames", max_frames=max_frames, - poll_interval=2.0, - max_poll_attempts=5, - max_retries=0, + poll_interval=COVER_POLL_INTERVAL, + max_poll_attempts=COVER_MAX_POLL_ATTEMPTS, + max_retries=1, ) if not snapshots: logger.warning("[数字人封面] MediaKit 未返回帧: %s", video_url[:80]) @@ -55,24 +107,26 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: if len(snapshots) == 1: return snapshots[0].get("image_url") or snapshots[0].get("url") or "" - # 下载各帧评分 + # 使用连接池下载各帧(复用 TCP 连接,减少延迟) import httpx candidates = [] - for snap in snapshots: - url = snap.get("image_url") or snap.get("url") or "" - if not url: - continue - tmp_path: Optional[str] = None - try: - resp = httpx.get(url, timeout=15, follow_redirects=True) - resp.raise_for_status() - with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: - tmp.write(resp.content) - tmp_path = tmp.name - candidates.append({"image_path": tmp_path, "url": url}) - except Exception: - candidates.append({"image_path": None, "url": url, "score": 0.0}) + with httpx.Client(timeout=FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client: + for snap in snapshots: + url = snap.get("image_url") or snap.get("url") or "" + if not url: + continue + tmp_path: Optional[str] = None + try: + resp = client.get(url) + resp.raise_for_status() + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + candidates.append({"image_path": tmp_path, "url": url}) + except Exception as e: + logger.warning("[数字人封面] 帧下载失败,跳过: url=%s err=%s", url[:80], e) + candidates.append({"image_path": None, "url": url, "score": 0.0}) if not candidates: return snapshots[0].get("image_url") or snapshots[0].get("url") or "" @@ -119,14 +173,16 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai- try: import httpx - resp = httpx.get(frame_url, timeout=30, follow_redirects=True) - resp.raise_for_status() - if not resp.content: - return frame_url + with httpx.Client(timeout=BEST_FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client: + resp = client.get(frame_url) + resp.raise_for_status() + if not resp.content: + logger.warning("[数字人封面] 帧图内容为空: %s", frame_url[:80]) + return frame_url - with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: - tmp.write(resp.content) - tmp_path = tmp.name + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name from packages.shared.storage import get_shared_storage_service diff --git a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py index d7f426241..8f80e2481 100644 --- a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py +++ b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py @@ -233,7 +233,7 @@ def test_smart_cover_selects_best_frame_and_persists(): with ( patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch, patch("packages.shared.cover_frame_scorer.score_frames") as score_patch, - patch("httpx.get") as http_get, + patch("httpx.Client") as http_client_cls, patch("packages.shared.storage.get_shared_storage_service") as storage_patch, ): mk = MagicMock() @@ -245,32 +245,107 @@ def test_smart_cover_selects_best_frame_and_persists(): {"url": "https://mk/f1.jpg", "score": 90.0, "image_path": cands[1]["image_path"]}, {"url": "https://mk/f0.jpg", "score": 60.0, "image_path": cands[0]["image_path"]}, ] + # httpx.Client 连接池 mock + client_instance = MagicMock() resp = MagicMock() resp.content = b"IMGDATA" resp.raise_for_status = MagicMock() - http_get.return_value = resp + client_instance.get.return_value = resp + client_instance.__enter__ = MagicMock(return_value=client_instance) + client_instance.__exit__ = MagicMock(return_value=False) + http_client_cls.return_value = client_instance + storage = MagicMock() - storage.upload_file.return_value = "https://oss/cover.jpg" + storage.public_url = "https://oss.example.com" + # video_url 不是自家 OSS,不重签 + storage.get_download_url.side_effect = lambda url, **kw: f"{url}?signed=1" + storage.upload_file.return_value = "https://oss.example.com/cover.jpg" storage_patch.return_value = storage - url = cov.generate_smart_cover("https://oss/avatar.mp4", job_id="job-1") + url = cov.generate_smart_cover("https://other-host/avatar.mp4", job_id="job-1") - assert url == "https://oss/cover.jpg" + assert "signed=1" in url or url == "https://oss.example.com/cover.jpg" mk.extract_frames.assert_called_once() score_patch.assert_called_once() + # 验证使用了增大的轮询参数 + call_kwargs = mk.extract_frames.call_args + assert call_kwargs.kwargs.get("poll_interval") == 3.0 or call_kwargs[1].get("poll_interval") == 3.0 + assert call_kwargs.kwargs.get("max_poll_attempts") == 20 or call_kwargs[1].get("max_poll_attempts") == 20 def test_smart_cover_returns_empty_when_mediakit_unavailable(): from app.services import ai_avatar_cover_service as cov - with patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch: + with ( + patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch, + patch("packages.shared.storage.get_shared_storage_service") as storage_patch, + ): mk = MagicMock() mk.is_available = False mk_patch.return_value = mk - url = cov.generate_smart_cover("https://oss/avatar.mp4") + storage = MagicMock() + storage.public_url = "https://oss.example.com" + storage_patch.return_value = storage + url = cov.generate_smart_cover("https://oss.example.com/avatar.mp4") assert url == "" +def test_sign_video_url_resigns_own_oss_url(): + """自家 OSS 私有桶 URL 应被重签为长有效期预签名 URL""" + from app.services.ai_avatar_cover_service import _sign_video_url_for_mediakit + + with patch("packages.shared.storage.get_shared_storage_service") as storage_patch: + storage = MagicMock() + storage.public_url = "https://oss.example.com" + storage.get_download_url.return_value = "https://oss.example.com/file.mp4?Expires=xxx&Signature=yyy" + storage_patch.return_value = storage + + result = _sign_video_url_for_mediakit("https://oss.example.com/file.mp4") + + assert "Signature=yyy" in result + storage.get_download_url.assert_called_once() + + +def test_sign_video_url_skips_external_url(): + """外部 URL(非自家 OSS)应原样返回,不做重签""" + from app.services.ai_avatar_cover_service import _sign_video_url_for_mediakit + + with patch("packages.shared.storage.get_shared_storage_service") as storage_patch: + storage = MagicMock() + storage.public_url = "https://oss.example.com" + storage_patch.return_value = storage + + result = _sign_video_url_for_mediakit("https://external-cdn.com/video.mp4") + + assert result == "https://external-cdn.com/video.mp4" + storage.get_download_url.assert_not_called() + + +def test_extract_frames_uses_extended_poll_params(): + """验证 select_best_cover_frame 使用增大后的轮询参数""" + from app.services import ai_avatar_cover_service as cov + + with ( + patch("packages.shared.mediakit_client.get_mediakit_client") as mk_patch, + patch("packages.shared.storage.get_shared_storage_service") as storage_patch, + ): + mk = MagicMock() + mk.is_available = True + mk.extract_frames.return_value = [{"image_url": "https://mk/f0.jpg"}] + mk_patch.return_value = mk + + storage = MagicMock() + storage.public_url = "https://oss.example.com" + storage_patch.return_value = storage + + cov.select_best_cover_frame("https://other/avatar.mp4", max_frames=3) + + call_kwargs = mk.extract_frames.call_args + assert call_kwargs.kwargs.get("poll_interval") == 3.0 or call_kwargs[1].get("poll_interval") == 3.0 + assert call_kwargs.kwargs.get("max_poll_attempts") == 20 or call_kwargs[1].get("max_poll_attempts") == 20 + assert call_kwargs.kwargs.get("max_retries") == 1 or call_kwargs[1].get("max_retries") == 1 + + # ── 渲染 script_id 可选(手动文案直生场景)────────────────────────────── -- 2.54.0