From 150dc17273d08a842d54d0802b9018538316bdae Mon Sep 17 00:00:00 2001 From: saas-backend Date: Thu, 10 Sep 2026 16:23:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ai-avatar):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=B0=81=E9=9D=A2=E6=8E=A5=E5=8F=A3=20502?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=A4=A7=E6=8A=BD=E5=B8=A7=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E6=97=B6=E9=97=B4+=E8=BF=9E=E6=8E=A5=E6=B1=A0=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD+video=5Furl=20=E9=87=8D=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因: 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 测试全过 --- apps/api/app/api/routes/ai_avatar_render.py | 13 ++- .../app/services/ai_avatar_cover_service.py | 99 ++++++++++++++----- .../test_ai_avatar_emotion_tts_lipsync.py | 72 +++++++++++++- 3 files changed, 154 insertions(+), 30 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 df0bd40aa..2b3422f90 100644 --- a/apps/api/app/services/ai_avatar_cover_service.py +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -15,9 +15,45 @@ 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 +67,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 +80,18 @@ 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 +100,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 +166,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..3df80e903 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() @@ -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,75 @@ 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 可选(手动文案直生场景)────────────────────────────── -- 2.54.0 From 1827a1fa49181da8bbedebb7e584d229979a747b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 10 Sep 2026 09:20:20 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- .../app/services/ai_avatar_cover_service.py | 8 +- apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 133 ++++-------------- .../components/PanelLipsyncPreview.tsx | 18 +-- .../ai-avatar/components/PanelScript.tsx | 4 +- .../test_ai_avatar_emotion_tts_lipsync.py | 4 +- 5 files changed, 44 insertions(+), 123 deletions(-) diff --git a/apps/api/app/services/ai_avatar_cover_service.py b/apps/api/app/services/ai_avatar_cover_service.py index 2b3422f90..02cb34b59 100644 --- a/apps/api/app/services/ai_avatar_cover_service.py +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -31,13 +31,14 @@ 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: @@ -82,7 +83,10 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: 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, + video_url[:80], + max_frames, + COVER_POLL_INTERVAL, + COVER_MAX_POLL_ATTEMPTS, ) snapshots = mk.extract_frames( 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 = () => {
= 2 ? "active" : ""}`}> 2 - - 对口型 / 标题 / 封面 / 生成 - + 对口型 / 标题 / 封面 / 生成
@@ -304,13 +279,8 @@ const AiAvatarPage: React.FC = () => { {currentStep === 1 && ( <> {/* 面板 1:出镜视频 */} -
-
togglePanel("video")} - > +
+
togglePanel("video")}> 出镜视频 ▼
@@ -325,13 +295,8 @@ const AiAvatarPage: React.FC = () => {
{/* 面板 2:配音库 */} -
-
togglePanel("voice")} - > +
+
togglePanel("voice")}> 配音库 ▼
@@ -352,13 +317,8 @@ const AiAvatarPage: React.FC = () => {
{/* 面板 3:文案 */} -
-
togglePanel("script")} - > +
+
togglePanel("script")}> 文案 ▼
@@ -373,10 +333,7 @@ const AiAvatarPage: React.FC = () => { {/* 步骤 1 底部按钮 */}
-
@@ -387,16 +344,9 @@ const AiAvatarPage: React.FC = () => { {currentStep === 2 && ( <> {/* 面板 4:插入画面 & 对口型预览 */} -
-
togglePanel("lipsync")} - > - - 对口型预览 - +
+
togglePanel("lipsync")}> + 对口型预览 ▼
@@ -411,13 +361,8 @@ const AiAvatarPage: React.FC = () => {
{/* 面板 5:标题配置 */} -
-
togglePanel("title")} - > +
+
togglePanel("title")}> 标题配置 ▼
@@ -430,13 +375,8 @@ const AiAvatarPage: React.FC = () => {
{/* 面板 6:封面 & 生成 */} -
-
togglePanel("cover")} - > +
+
togglePanel("cover")}> 封面 & 生成 ▼
@@ -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 = () => {
e.stopPropagation()}>
对口型生成 -
@@ -592,10 +527,7 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "generating" && ( - )} @@ -663,17 +595,12 @@ const ScriptSelectModalLazy: React.FC<{ ) : (
{filtered.map((s) => ( -
onSelect(s)} - > +
onSelect(s)}> 📄
{s.title}
- {s.char_count}字 ·{" "} - {new Date(s.created_at).toLocaleDateString()} + {s.char_count}字 · {new Date(s.created_at).toLocaleDateString()}
diff --git a/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx index 1e32c5186..26789632d 100755 --- a/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelLipsyncPreview.tsx @@ -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({
🎞️ 插入画面 {bRollSegments.length > 0 && ( - - 🎬 {bRollSegments.length} 个画面 - + 🎬 {bRollSegments.length} 个画面 )}
@@ -75,10 +72,7 @@ export function PanelLipsyncPreview({ alt={seg.asset.name} /> ) : ( - + 🎬 )} @@ -157,11 +151,7 @@ export function PanelLipsyncPreview({
{isDone ? ( - ) : isGenerating ? ( diff --git a/apps/web/src/pages/ai-avatar/components/PanelScript.tsx b/apps/web/src/pages/ai-avatar/components/PanelScript.tsx index 7ff8b91d7..551e87131 100755 --- a/apps/web/src/pages/ai-avatar/components/PanelScript.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelScript.tsx @@ -55,9 +55,7 @@ export function PanelScript({ value={scriptText} readOnly={scriptTab === "library"} placeholder={ - scriptTab === "library" - ? "点击上方按钮,从文案库选择文案…" - : "请输入数字人口播文案…" + scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…" } onChange={(e) => onScriptTextChange(e.target.value)} /> diff --git a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py index 3df80e903..217b0d6ba 100644 --- a/tests/unit/test_ai_avatar_emotion_tts_lipsync.py +++ b/tests/unit/test_ai_avatar_emotion_tts_lipsync.py @@ -289,7 +289,9 @@ def test_sign_video_url_resigns_own_oss_url(): 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.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") -- 2.54.0