fix(ai-avatar): 修复智能封面502 - cover_service视频URL重签+轮询扩容+连接池+路由异常兜底 #1843

Merged
xiaoxia merged 1 commits from fix/cover-403-and-robustness into develop 2026-09-10 17:55:44 +08:00
3 changed files with 174 additions and 34 deletions
+11 -2
View File
@@ -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")
@@ -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
@@ -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 可选(手动文案直生场景)──────────────────────────────