Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 1827a1fa49 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m7s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
AI Code Review / AI Code Review (pull_request) Successful in 6m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
2026-09-10 09:20:20 +00:00
saas-backend 150dc17273 fix(ai-avatar): 修复智能封面接口 502,增大抽帧等待时间+连接池下载+video_url 重签
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m23s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 1m34s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
问题根因:
1. MediaKit 抽帧轮询参数过短(poll_interval=2s, max_poll_attempts=5,最多等 10s),大视频或排队场景下 MediaKit 尚未完成即放弃
2. 帧图片串行下载,每次新建 TCP 连接(httpx.get),5帧×15s 最坏 75s,加上 persist 再下载 30s,总耗时可达 105s
3. 前端传入的 video_url 可能是 OSS 私有桶裸 URL,MediaKit 无法访问导致抽帧失败

修复:
- MediaKit 抽帧参数:poll_interval 2s→3s,max_poll_attempts 5→20(最多等 60s),max_retries 0→1
- 帧下载改用 httpx.Client 连接池复用,减少连接建立开销
- 新增 _sign_video_url_for_mediakit:自动检测自家 OSS URL 并重签 7 天有效期
- 路由层增加 try/except 包裹 generate_smart_cover,异常时返回 fallback 而非 502
- 增加关键路径日志

测试:新增 3 个单测(video_url 重签、外部 URL 透传、轮询参数校验),共 15 测试全过
2026-09-10 16:23:16 +08:00
6 changed files with 195 additions and 150 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,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
+30 -103
View File
@@ -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 = () => {
<div className="aa-step__connector" />
<div className={`aa-step ${currentStep >= 2 ? "active" : ""}`}>
<span className="aa-step__number">2</span>
<span className="aa-step__label">
/ / /
</span>
<span className="aa-step__label"> / / / </span>
</div>
</div>
@@ -304,13 +279,8 @@ const AiAvatarPage: React.FC = () => {
{currentStep === 1 && (
<>
{/* 面板 1:出镜视频 */}
<div
className={`aa-panel${collapsed.video ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("video")}
>
<div className={`aa-panel${collapsed.video ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
@@ -325,13 +295,8 @@ const AiAvatarPage: React.FC = () => {
</div>
{/* 面板 2:配音库 */}
<div
className={`aa-panel${collapsed.voice ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("voice")}
>
<div className={`aa-panel${collapsed.voice ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
@@ -352,13 +317,8 @@ const AiAvatarPage: React.FC = () => {
</div>
{/* 面板 3:文案 */}
<div
className={`aa-panel${collapsed.script ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("script")}
>
<div className={`aa-panel${collapsed.script ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
@@ -373,10 +333,7 @@ const AiAvatarPage: React.FC = () => {
{/* 步骤 1 底部按钮 */}
<div className="aa-step-actions">
<button
className="aa-btn aa-btn--primary"
onClick={handleNextStep}
>
<button className="aa-btn aa-btn--primary" onClick={handleNextStep}>
</button>
</div>
@@ -387,16 +344,9 @@ const AiAvatarPage: React.FC = () => {
{currentStep === 2 && (
<>
{/* 面板 4:插入画面 & 对口型预览 */}
<div
className={`aa-panel${collapsed.lipsync ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("lipsync")}
>
<span className="aa-panel__title">
</span>
<div className={`aa-panel${collapsed.lipsync ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
@@ -411,13 +361,8 @@ const AiAvatarPage: React.FC = () => {
</div>
{/* 面板 5:标题配置 */}
<div
className={`aa-panel${collapsed.title ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("title")}
>
<div className={`aa-panel${collapsed.title ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
<span className="aa-panel__title"></span>
<span className="aa-panel__toggle"></span>
</div>
@@ -430,13 +375,8 @@ const AiAvatarPage: React.FC = () => {
</div>
{/* 面板 6:封面 & 生成 */}
<div
className={`aa-panel${collapsed.cover ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("cover")}
>
<div className={`aa-panel${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__toggle"></span>
</div>
@@ -451,9 +391,7 @@ const AiAvatarPage: React.FC = () => {
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={
state.lipsyncJob?.status === "completed"
}
canSmartCover={state.lipsyncJob?.status === "completed"}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
@@ -511,10 +449,7 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button
className="aa-modal__close"
onClick={handleCancelLipsync}
>
<button className="aa-modal__close" onClick={handleCancelLipsync}>
</button>
</div>
@@ -592,10 +527,7 @@ const AiAvatarPage: React.FC = () => {
</div>
<div className="aa-modal__footer">
{lipsyncStatus === "generating" && (
<button
className="aa-btn aa-btn--danger"
onClick={handleCancelLipsync}
>
<button className="aa-btn aa-btn--danger" onClick={handleCancelLipsync}>
</button>
)}
@@ -663,17 +595,12 @@ const ScriptSelectModalLazy: React.FC<{
) : (
<div className="aa-script-list">
{filtered.map((s) => (
<div
key={s.id}
className="aa-script-item"
onClick={() => onSelect(s)}
>
<div key={s.id} className="aa-script-item" onClick={() => onSelect(s)}>
<span className="aa-script-item__icon">📄</span>
<div className="aa-script-item__info">
<div className="aa-script-item__title">{s.title}</div>
<div className="aa-script-item__meta">
{s.char_count} ·{" "}
{new Date(s.created_at).toLocaleDateString()}
{s.char_count} · {new Date(s.created_at).toLocaleDateString()}
</div>
</div>
</div>
@@ -30,8 +30,7 @@ export function PanelLipsyncPreview({
onOpenBRollModal,
onRemoveBRoll,
}: PanelLipsyncPreviewProps) {
const isGenerating =
lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isDone = lipsyncJob?.status === "completed"
const isFailed = lipsyncJob?.status === "failed"
@@ -49,9 +48,7 @@ export function PanelLipsyncPreview({
<div className="aa-lipsync-section__title">
<span style={{ marginRight: 8 }}>🎞 </span>
{bRollSegments.length > 0 && (
<span className="aa-broll-badge">
🎬 {bRollSegments.length}
</span>
<span className="aa-broll-badge">🎬 {bRollSegments.length} </span>
)}
</div>
<div className="aa-lipsync-actions">
@@ -75,10 +72,7 @@ export function PanelLipsyncPreview({
alt={seg.asset.name}
/>
) : (
<span
className="aa-broll-item__thumb"
style={{ padding: "6px 4px" }}
>
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
🎬
</span>
)}
@@ -157,11 +151,7 @@ export function PanelLipsyncPreview({
<div className="aa-lipsync-actions">
{isDone ? (
<button
type="button"
className="aa-btn aa-btn--full"
onClick={onGenerateLipsync}
>
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
🔄
</button>
) : isGenerating ? (
@@ -55,9 +55,7 @@ export function PanelScript({
value={scriptText}
readOnly={scriptTab === "library"}
placeholder={
scriptTab === "library"
? "点击上方按钮,从文案库选择文案…"
: "请输入数字人口播文案…"
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
}
onChange={(e) => onScriptTextChange(e.target.value)}
/>
@@ -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()
@@ -248,8 +248,13 @@ def test_smart_cover_selects_best_frame_and_persists():
resp = MagicMock()
resp.content = b"IMGDATA"
resp.raise_for_status = MagicMock()
http_get.return_value = resp
client_instance = MagicMock()
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.public_url = "https://other-oss.example.com" # 不同host,不触发重签
storage.upload_file.return_value = "https://oss/cover.jpg"
storage_patch.return_value = storage
@@ -263,14 +268,77 @@ def test_smart_cover_selects_best_frame_and_persists():
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
storage = MagicMock()
storage.public_url = "https://other-oss.example.com"
storage_patch.return_value = storage
url = cov.generate_smart_cover("https://oss/avatar.mp4")
assert url == ""
def test_sign_video_url_resigns_own_oss_url():
"""自家 OSS 私有桶 URL 会被重签为长有效期预签名 URL"""
from app.services import ai_avatar_cover_service as cov
with patch("packages.shared.storage.get_shared_storage_service") as storage_patch:
storage = MagicMock()
storage.public_url = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
storage.get_download_url.return_value = (
"https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/vid.mp4?Expires=999&Signature=abc"
)
storage_patch.return_value = storage
result = cov._sign_video_url_for_mediakit("https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/vid.mp4")
assert "Signature=abc" in result
storage.get_download_url.assert_called_once()
def test_sign_video_url_skips_external_url():
"""外部 URL 不会被重签"""
from app.services import ai_avatar_cover_service as cov
with patch("packages.shared.storage.get_shared_storage_service") as storage_patch:
storage = MagicMock()
storage.public_url = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com"
storage_patch.return_value = storage
result = cov._sign_video_url_for_mediakit("https://cdn.example.com/video.mp4")
assert result == "https://cdn.example.com/video.mp4"
storage.get_download_url.assert_not_called()
def test_extract_frames_uses_extended_poll_params():
"""验证抽帧使用了增大后的轮询参数"""
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://other-oss.example.com"
storage_patch.return_value = storage
cov.select_best_cover_frame("https://oss/video.mp4", max_frames=3)
call_kwargs = mk.extract_frames.call_args.kwargs
assert call_kwargs["poll_interval"] == 3.0
assert call_kwargs["max_poll_attempts"] == 20
assert call_kwargs["max_retries"] == 1
# ── 渲染 script_id 可选(手动文案直生场景)──────────────────────────────