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 df0bd40aa..02cb34b59 100644 --- a/apps/api/app/services/ai_avatar_cover_service.py +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -15,9 +15,46 @@ 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 + + +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=7 * 24 * 3600) + 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 +68,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 +81,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 +104,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 +170,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/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 06bf75464..bd2fe67f7 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -30,13 +30,7 @@ import { } from "./utils/contract" /** 面板折叠状态 */ -type PanelKey = - | "video" - | "voice" - | "script" - | "lipsync" - | "title" - | "cover" +type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover" const AiAvatarPage: React.FC = () => { const state = useAiAvatar() @@ -52,9 +46,9 @@ const AiAvatarPage: React.FC = () => { /* ── 对口型生成弹窗 ── */ const [showLipsyncModal, setShowLipsyncModal] = useState(false) - const [lipsyncStatus, setLipsyncStatus] = useState< - "generating" | "completed" | "failed" - >("generating") + const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">( + "generating", + ) const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") /* ─ 智能封面加载态 ── */ const [smartCoverLoading, setSmartCoverLoading] = useState(false) @@ -153,9 +147,7 @@ const AiAvatarPage: React.FC = () => { } else if (updated.status === "failed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) setLipsyncStatus("failed") - setLipsyncErrorMessage( - updated.error_message || "对口型生成失败", - ) + setLipsyncErrorMessage(updated.error_message || "对口型生成失败") } } catch (err) { console.error("[对口型] 轮询错误:", err) @@ -168,18 +160,10 @@ const AiAvatarPage: React.FC = () => { message: err instanceof Error ? err.message : String(err), }) setShowLipsyncModal(false) - message.error( - err instanceof Error ? err.message : "对口型任务提交失败,请重试", - ) + message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试") } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - state.selectedVideo, - state.selectedVoice, - state.scriptText, - state.speed, - state.emotion, - ]) + }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion]) const handleCancelLipsync = useCallback(() => { if (lipsyncTimerRef.current) { @@ -210,18 +194,13 @@ const AiAvatarPage: React.FC = () => { script_id: state.script?.id, b_roll_segments: state.bRollSegments as never, title_config: buildTitleConfigPayload(state.titleConfig), - cover_config: buildCoverConfigPayload( - state.coverConfig, - state.coverConfig.smart_cover_url, - ), + cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url), resolution: state.resolution, }) message.success("渲染任务已提交,可在视频管理中查看进度") } catch (err) { console.error("渲染任务提交失败:", err) - message.error( - err instanceof Error ? err.message : "渲染任务提交失败,请重试", - ) + message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试") } finally { state.setIsGenerating(false) } @@ -258,9 +237,7 @@ const AiAvatarPage: React.FC = () => { } } catch (err) { console.error("智能封面生成失败:", err) - message.error( - err instanceof Error ? err.message : "智能封面生成失败,请重试", - ) + message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试") } finally { setSmartCoverLoading(false) } @@ -293,9 +270,7 @@ const AiAvatarPage: React.FC = () => {