feat: #1208 AI封面接入MediaKit视频截帧 #1214
@@ -184,6 +184,29 @@ def editor_generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url = None
|
||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
try:
|
||||
from app.database import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
with get_db_session() as session:
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
first_asset = asset_repo.get(body.asset_ids[0])
|
||||
if first_asset and first_asset.storage_key:
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
||||
logger.info(
|
||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
||||
body.asset_ids[0],
|
||||
primary_video_url[:80] if primary_video_url else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
@@ -191,6 +214,7 @@ def editor_generate_cover(
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
|
||||
@@ -69,6 +69,11 @@ class SharedSettings(BaseSettings):
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
|
||||
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
|
||||
mediakit_api_key: str = ""
|
||||
mediakit_base_url: str = "https://mediakit.cn-beijing.volces.com/api/v1"
|
||||
mediakit_timeout: int = 60
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
"""返回实际使用的数据库 URL。
|
||||
|
||||
@@ -267,15 +267,20 @@ def _call_ai_cover_service(
|
||||
asset_ids: List[str],
|
||||
cover_type: str,
|
||||
frame_time: float | None = None,
|
||||
primary_video_url: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 封面生成服务(stub)
|
||||
"""调用 AI 封面生成服务.
|
||||
|
||||
TODO: 接入真实 AI 服务,从视频中选帧或生成封面。
|
||||
当前返回模拟封面数据。
|
||||
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
|
||||
失败或未配置时降级为 stub 行为。
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_ids: 素材 ID 列表
|
||||
cover_type: 封面类型
|
||||
frame_time: 手动选帧时间点
|
||||
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
|
||||
"""
|
||||
# 模拟 AI 处理耗时
|
||||
time.sleep(0.3)
|
||||
|
||||
if cover_type == "upload":
|
||||
return {
|
||||
"type": "upload",
|
||||
@@ -290,7 +295,48 @@ def _call_ai_cover_service(
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
# ai_frame / ai_regenerate
|
||||
# ai_frame / ai_regenerate - 尝试调用 MediaKit
|
||||
if primary_video_url:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
if client.is_available:
|
||||
try:
|
||||
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
|
||||
frames = client.extract_frames(
|
||||
video_url=primary_video_url,
|
||||
strategy="SceneChange",
|
||||
max_frames=5,
|
||||
)
|
||||
|
||||
if frames and len(frames) > 0:
|
||||
# 选择第一帧(SceneChange 策略的第一帧通常是最佳画面)
|
||||
best_frame = frames[0]
|
||||
image_url = best_frame.get("image_url", "")
|
||||
timestamp = best_frame.get("timestamp", 0.0)
|
||||
|
||||
if image_url:
|
||||
logger.info(
|
||||
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
|
||||
plan_id,
|
||||
timestamp,
|
||||
image_url[:80],
|
||||
)
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": image_url,
|
||||
"frame_time": round(timestamp, 1),
|
||||
"confidence": 0.85,
|
||||
}
|
||||
else:
|
||||
logger.warning("MediaKit 返回的帧无 image_url")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 抽帧失败,降级到 stub: %s", str(e))
|
||||
|
||||
# 降级:stub 行为
|
||||
logger.info("使用 stub 封面: plan_id=%s", plan_id)
|
||||
time.sleep(0.3)
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": f"/api/v1/assets/placeholder/cover?plan={plan_id}",
|
||||
@@ -351,6 +397,7 @@ def run_generate_cover(
|
||||
asset_ids: List[str],
|
||||
cover_type: str = "ai_frame",
|
||||
frame_time: float | None = None,
|
||||
primary_video_url: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 AI 封面生成
|
||||
|
||||
@@ -359,21 +406,24 @@ def run_generate_cover(
|
||||
asset_ids: 素材 ID 列表(用于确定视频来源)
|
||||
cover_type: 封面类型 (ai_frame / manual / upload / ai_regenerate)
|
||||
frame_time: 手动选帧时间点(仅 manual 模式使用)
|
||||
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
"""
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s assets=%d",
|
||||
"AI 封面生成: plan_id=%s type=%s assets=%d has_video_url=%s",
|
||||
plan_id,
|
||||
cover_type,
|
||||
len(asset_ids),
|
||||
bool(primary_video_url),
|
||||
)
|
||||
result = _call_ai_cover_service(
|
||||
plan_id=plan_id,
|
||||
asset_ids=asset_ids,
|
||||
cover_type=cover_type,
|
||||
frame_time=frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
logger.info(
|
||||
"AI 封面生成完成: plan_id=%s type=%s url=%s",
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
"""MediaKit API 客户端(共享层).
|
||||
|
||||
火山引擎 AI MediaKit 提供视频抽帧、视频理解等能力。
|
||||
API 和 Worker 共用此客户端。
|
||||
|
||||
使用方式:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
if client.is_available:
|
||||
frames = client.extract_frames(video_url="https://...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MediaKitClient:
|
||||
"""MediaKit API 客户端.
|
||||
|
||||
封装视频抽帧、视频理解等接口,支持异步任务轮询。
|
||||
未配置 API Key 时 is_available 为 False,调用方应降级处理。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_shared_settings()
|
||||
self.api_key: str = settings.mediakit_api_key
|
||||
self.base_url: str = settings.mediakit_base_url.rstrip("/")
|
||||
self.timeout: int = settings.mediakit_timeout
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
return bool(self.api_key)
|
||||
|
||||
def extract_frames(
|
||||
self,
|
||||
video_url: str,
|
||||
strategy: str = "SceneChange",
|
||||
max_frames: int = 10,
|
||||
poll_interval: float = 2.0,
|
||||
max_poll_attempts: int = 30,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""调用 MediaKit 视频抽帧接口.
|
||||
|
||||
Args:
|
||||
video_url: 视频 URL(需可公开访问)
|
||||
strategy: 抽帧策略
|
||||
- TimeInterval: 按固定时间间隔
|
||||
- SpecifiedTime: 按指定时间点
|
||||
- SpecifiedFrames: 首尾帧 + 指定帧数
|
||||
- SceneChange: 场景变化检测(推荐用于封面选取)
|
||||
max_frames: 最大返回帧数
|
||||
poll_interval: 轮询间隔(秒)
|
||||
max_poll_attempts: 最大轮询次数
|
||||
|
||||
Returns:
|
||||
帧列表 [{"image_url": "...", "timestamp": 1.5}, ...]
|
||||
失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
logger.warning("MediaKit 未配置,跳过抽帧")
|
||||
return None
|
||||
|
||||
# 提交抽帧任务
|
||||
task_id = self._submit_extract_task(video_url, strategy, max_frames)
|
||||
if not task_id:
|
||||
return None
|
||||
|
||||
# 轮询任务状态
|
||||
return self._poll_task_result(task_id, poll_interval, max_poll_attempts)
|
||||
|
||||
def _submit_extract_task(
|
||||
self,
|
||||
video_url: str,
|
||||
strategy: str,
|
||||
max_frames: int,
|
||||
) -> Optional[str]:
|
||||
"""提交抽帧任务,返回 task_id."""
|
||||
url = f"{self.base_url}/tools/extract-frames"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"video_url": video_url,
|
||||
"strategy": strategy,
|
||||
"max_frames": max_frames,
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.post(url, headers=headers, json=payload, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
task_id = data.get("task_id")
|
||||
if not task_id:
|
||||
logger.error("MediaKit 抽帧任务提交失败: 无 task_id. response=%s", data)
|
||||
return None
|
||||
|
||||
logger.info("MediaKit 抽帧任务已提交: task_id=%s strategy=%s", task_id, strategy)
|
||||
return task_id
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 抽帧任务提交异常: %s", str(e))
|
||||
return None
|
||||
|
||||
def _poll_task_result(
|
||||
self,
|
||||
task_id: str,
|
||||
poll_interval: float,
|
||||
max_poll_attempts: int,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""轮询任务状态,返回结果."""
|
||||
url = f"{self.base_url}/tasks/{task_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
for attempt in range(max_poll_attempts):
|
||||
try:
|
||||
response = httpx.get(url, headers=headers, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
status = data.get("status")
|
||||
if status == "success":
|
||||
result = data.get("result", {})
|
||||
snapshots = result.get("snapshots", [])
|
||||
logger.info(
|
||||
"MediaKit 抽帧完成: task_id=%s frames=%d",
|
||||
task_id,
|
||||
len(snapshots),
|
||||
)
|
||||
return snapshots
|
||||
|
||||
elif status == "failed":
|
||||
error_msg = data.get("error", "unknown error")
|
||||
logger.error("MediaKit 抽帧任务失败: task_id=%s error=%s", task_id, error_msg)
|
||||
return None
|
||||
|
||||
# status == "processing" or "pending"
|
||||
logger.debug(
|
||||
"MediaKit 抽帧进行中: task_id=%s status=%s attempt=%d/%d",
|
||||
task_id,
|
||||
status,
|
||||
attempt + 1,
|
||||
max_poll_attempts,
|
||||
)
|
||||
time.sleep(poll_interval)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 轮询异常: task_id=%s error=%s", task_id, str(e))
|
||||
time.sleep(poll_interval)
|
||||
|
||||
logger.error("MediaKit 抽帧超时: task_id=%s max_attempts=%d", task_id, max_poll_attempts)
|
||||
return None
|
||||
|
||||
|
||||
# ── 单例管理 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_client_instance: Optional[MediaKitClient] = None
|
||||
|
||||
|
||||
def get_mediakit_client() -> MediaKitClient:
|
||||
"""获取 MediaKit 客户端单例."""
|
||||
global _client_instance
|
||||
if _client_instance is None:
|
||||
_client_instance = MediaKitClient()
|
||||
return _client_instance
|
||||
Executable
+320
@@ -0,0 +1,320 @@
|
||||
"""MediaKit AI 封面生成单元测试.
|
||||
|
||||
测试 #1208: AI封面接入MediaKit视频截帧
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.mediakit_client import MediaKitClient, get_mediakit_client
|
||||
|
||||
|
||||
class TestMediaKitClient:
|
||||
"""MediaKit 客户端测试."""
|
||||
|
||||
def test_is_available_with_key(self):
|
||||
"""配置了 API Key 时可用."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
|
||||
mock_settings.return_value.mediakit_timeout = 60
|
||||
|
||||
client = MediaKitClient()
|
||||
assert client.is_available is True
|
||||
|
||||
def test_is_available_without_key(self):
|
||||
"""未配置 API Key 时不可用."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = ""
|
||||
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
|
||||
mock_settings.return_value.mediakit_timeout = 60
|
||||
|
||||
client = MediaKitClient()
|
||||
assert client.is_available is False
|
||||
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
def test_extract_frames_success(self, mock_get, mock_post):
|
||||
"""抽帧成功."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
|
||||
mock_settings.return_value.mediakit_timeout = 60
|
||||
|
||||
# Mock submit task
|
||||
mock_post.return_value.status_code = 200
|
||||
mock_post.return_value.json.return_value = {"task_id": "task-123"}
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
# Mock poll task
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.json.return_value = {
|
||||
"status": "success",
|
||||
"result": {
|
||||
"snapshots": [
|
||||
{"image_url": "https://example.com/frame1.jpg", "timestamp": 2.5},
|
||||
{"image_url": "https://example.com/frame2.jpg", "timestamp": 5.0},
|
||||
]
|
||||
},
|
||||
}
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
client = MediaKitClient()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="SceneChange",
|
||||
max_frames=5,
|
||||
)
|
||||
|
||||
assert frames is not None
|
||||
assert len(frames) == 2
|
||||
assert frames[0]["image_url"] == "https://example.com/frame1.jpg"
|
||||
assert frames[0]["timestamp"] == 2.5
|
||||
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
def test_extract_frames_submit_failed(self, mock_post):
|
||||
"""提交任务失败."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
|
||||
mock_settings.return_value.mediakit_timeout = 60
|
||||
|
||||
mock_post.side_effect = Exception("Network error")
|
||||
|
||||
client = MediaKitClient()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="SceneChange",
|
||||
max_frames=5,
|
||||
)
|
||||
|
||||
assert frames is None
|
||||
|
||||
@patch("packages.shared.mediakit_client.httpx.post")
|
||||
@patch("packages.shared.mediakit_client.httpx.get")
|
||||
def test_extract_frames_task_failed(self, mock_get, mock_post):
|
||||
"""任务执行失败."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
|
||||
mock_settings.return_value.mediakit_timeout = 60
|
||||
|
||||
# Mock submit task
|
||||
mock_post.return_value.status_code = 200
|
||||
mock_post.return_value.json.return_value = {"task_id": "task-123"}
|
||||
mock_post.return_value.raise_for_status = Mock()
|
||||
|
||||
# Mock poll task - failed
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.json.return_value = {
|
||||
"status": "failed",
|
||||
"error": "Video processing failed",
|
||||
}
|
||||
mock_get.return_value.raise_for_status = Mock()
|
||||
|
||||
client = MediaKitClient()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="SceneChange",
|
||||
max_frames=5,
|
||||
)
|
||||
|
||||
assert frames is None
|
||||
|
||||
def test_extract_frames_not_available(self):
|
||||
"""客户端不可用时返回 None."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = ""
|
||||
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
|
||||
mock_settings.return_value.mediakit_timeout = 60
|
||||
|
||||
client = MediaKitClient()
|
||||
frames = client.extract_frames(
|
||||
video_url="https://example.com/video.mp4",
|
||||
strategy="SceneChange",
|
||||
max_frames=5,
|
||||
)
|
||||
|
||||
assert frames is None
|
||||
|
||||
|
||||
class TestAICoverService:
|
||||
"""AI 封面服务测试."""
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_with_mediakit_success(self, mock_get_client):
|
||||
"""MediaKit 抽帧成功."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.return_value = [{"image_url": "https://example.com/frame.jpg", "timestamp": 3.5}]
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"] == "https://example.com/frame.jpg"
|
||||
assert result["frame_time"] == 3.5
|
||||
assert result["confidence"] == 0.85
|
||||
|
||||
mock_client.extract_frames.assert_called_once()
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_with_mediakit_failure_fallback(self, mock_get_client):
|
||||
"""MediaKit 失败时降级到 stub."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.side_effect = Exception("API error")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
# 应该降级到 stub
|
||||
assert result["type"] == "ai_frame"
|
||||
assert "placeholder" in result["image_url"]
|
||||
|
||||
def test_call_ai_cover_without_video_url_fallback(self):
|
||||
"""没有视频 URL 时使用 stub."""
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url=None,
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert "placeholder" in result["image_url"]
|
||||
|
||||
def test_call_ai_cover_upload_type(self):
|
||||
"""upload 类型直接返回."""
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="upload",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "upload"
|
||||
assert result["image_url"] == ""
|
||||
|
||||
def test_call_ai_cover_manual_type(self):
|
||||
"""manual 类型返回指定时间点."""
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="manual",
|
||||
frame_time=5.0,
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "manual"
|
||||
assert result["frame_time"] == 5.0
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_mediakit_not_available(self, mock_get_client):
|
||||
"""MediaKit 未配置时使用 stub."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = False
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert "placeholder" in result["image_url"]
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_empty_frames_fallback(self, mock_get_client):
|
||||
"""MediaKit 返回空帧列表时降级."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert "placeholder" in result["image_url"]
|
||||
|
||||
|
||||
class TestGenerateCover:
|
||||
"""run_generate_cover 测试."""
|
||||
|
||||
@patch("packages.shared.ai_service._call_ai_cover_service")
|
||||
def test_run_generate_cover_passes_video_url(self, mock_cover_service):
|
||||
"""正确传递 video_url 参数."""
|
||||
mock_cover_service.return_value = {
|
||||
"type": "ai_frame",
|
||||
"image_url": "https://example.com/cover.jpg",
|
||||
"frame_time": 2.0,
|
||||
"confidence": 0.9,
|
||||
}
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
mock_cover_service.assert_called_once()
|
||||
call_kwargs = mock_cover_service.call_args[1]
|
||||
assert call_kwargs["primary_video_url"] == "https://example.com/video.mp4"
|
||||
|
||||
@patch("packages.shared.ai_service._call_ai_cover_service")
|
||||
def test_run_generate_cover_without_video_url(self, mock_cover_service):
|
||||
"""不传 video_url 时默认为 None."""
|
||||
mock_cover_service.return_value = {
|
||||
"type": "ai_frame",
|
||||
"image_url": "https://example.com/cover.jpg",
|
||||
"frame_time": 2.0,
|
||||
"confidence": 0.9,
|
||||
}
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
call_kwargs = mock_cover_service.call_args[1]
|
||||
assert call_kwargs["primary_video_url"] is None
|
||||
Reference in New Issue
Block a user