perf: pre-extract cover frames during render + FFmpeg fallback
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build 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 API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 46s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Failing after 2m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (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

- Add extract_cover_candidates() and extract_and_upload_cover_frames()
  to thumbnail_generator.py: extract 3 frames at 25%/50%/75% of video
- Add cover_candidates field to RenderAdapterResult
- Call frame extraction after thumbnail gen in render_adapter._do_render()
- Write cover_candidates to plan.config in edit_plan_generation worker
- Check pre-stored frames in generation_cover.py before calling AI service
- Replace MediaKit remote API with FFmpeg HTTP range-request in
  _call_ai_cover_service (no full video download needed)
- Fix CoverTemplateResponse ValidationError when config is None
- Add unit tests for all new code paths
- Update existing tests to match new FFmpeg-based behavior
This commit is contained in:
CI Bot
2026-08-13 19:54:46 +08:00
parent 17174e2cf5
commit bbf3006d0c
9 changed files with 710 additions and 143 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ def list_cover_templates(
thumbnail_url=t.thumbnail_url,
is_system=t.is_system,
created_at=t.created_at,
config=t.config,
config=t.config or {},
)
for t in items
],
@@ -182,6 +182,27 @@ def generate_cover(
detail=f"获取预览视频URL失败: {e}",
) from e
# 优先使用渲染时预抽的封面候选帧(跳过 MediaKit,秒级返回)
cover_candidates = (plan.config or {}).get("cover_candidates", [])
if cover_candidates and body.cover_type in ("ai_frame", "ai_regenerate"):
logger.info(
"[封面生成] 使用预存封面候选帧: plan_id=%s count=%d",
plan_id, len(cover_candidates),
)
first_frame = cover_candidates[0]
cover_data = {
"type": "ai_frame",
"image_url": first_frame.get("image_url", ""),
"frame_time": first_frame.get("frame_time", 0.0),
"confidence": 0.9,
}
if cover_data["image_url"]:
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
from packages.shared.ai_service import run_generate_cover
try:
@@ -74,6 +74,7 @@ class RenderAdapterResult:
failed_clip_ids: list[str] = None # 失败的 clip id 列表
error_message: str = ""
error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查
cover_candidates: list[dict] | None = None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -568,6 +569,25 @@ class RenderAdapter:
thumb_err,
)
# 7. 抽取封面候选帧并上传 OSS(失败不阻断主流程)
cover_candidates = None
try:
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, num_frames=3
)
if cover_candidates:
logger.info(
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
plan_id, len(cover_candidates),
)
except Exception as cover_err:
logger.warning(
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
plan_id, cover_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
@@ -599,6 +619,7 @@ class RenderAdapter:
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
)
def render_from_memory(
@@ -155,3 +155,122 @@ def generate_and_upload_thumbnail(
Path(thumbnail_path).unlink(missing_ok=True)
except Exception:
pass
def extract_cover_candidates(
video_path: str,
num_frames: int = 3,
*,
width: int = 640,
timeout: int = 30,
) -> list[dict]:
"""在视频时长 25%/50%/75% 处各抽一帧,返回候选帧信息列表。
Args:
video_path: 视频文件路径
num_frames: 抽帧数量(默认 3
width: 输出宽度
timeout: 单帧超时(秒)
Returns:
[{"local_path": "...", "frame_time": 5.0}, ...]
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
try:
duration = probe_duration(video_path)
except Exception:
duration = 0.0
if duration <= 0:
duration = 5.0 # fallback
# 计算抽帧时间点:25%, 50%, 75%
ratios = []
for i in range(1, num_frames + 1):
ratios.append(i / (num_frames + 1))
results = []
for idx, ratio in enumerate(ratios):
frame_time = max(0.5, duration * ratio)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
output_path = tmp.name
try:
seek_str = _format_seek_time(frame_time)
scale_filter = f"scale={width}:-1:force_original_aspect_ratio=decrease,format=yuvj420p"
cmd = [
FFMPEG_BIN, "-y",
"-ss", seek_str,
"-i", video_path,
"-vframes", "1",
"-vf", scale_filter,
"-q:v", "2",
output_path,
]
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
if Path(output_path).exists() and Path(output_path).stat().st_size > 0:
results.append({
"local_path": output_path,
"frame_time": round(frame_time, 2),
})
else:
Path(output_path).unlink(missing_ok=True)
except Exception as e:
logger.warning("封面候选帧抽取失败 ratio=%.2f: %s", ratio, e)
Path(output_path).unlink(missing_ok=True)
return results
def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
num_frames: int = 3,
) -> list[dict]:
"""抽取封面候选帧并上传到 OSS。
Args:
video_path: 本地视频路径
plan_id: 剪辑计划 ID(用于 OSS 路径)
num_frames: 抽帧数量
Returns:
[{"image_url": "https://...", "frame_time": 5.0, "storage_key": "covers/xxx/frame_0.jpg"}, ...]
"""
candidates = extract_cover_candidates(video_path, num_frames=num_frames)
if not candidates:
logger.warning("封面候选帧抽取为空: plan_id=%s", plan_id)
return []
results = []
for idx, cand in enumerate(candidates):
local_path = cand["local_path"]
frame_time = cand["frame_time"]
storage_key = f"covers/{plan_id}/frame_{idx}.jpg"
try:
from video_processing.oss_helpers import upload_to_oss
url = upload_to_oss(local_path, storage_key)
if url:
results.append({
"image_url": url,
"frame_time": frame_time,
"storage_key": storage_key,
})
logger.info(
"封面候选帧上传成功: plan_id=%s idx=%d frame_time=%.2f",
plan_id, idx, frame_time,
)
except Exception as e:
logger.warning("封面候选帧上传失败: plan_id=%s idx=%d error=%s", plan_id, idx, e)
finally:
try:
Path(local_path).unlink(missing_ok=True)
except Exception:
pass
return results
@@ -256,6 +256,16 @@ def _render_with_unified(
rendered_clip_ids = result.rendered_clip_ids or []
failed_clip_ids = result.failed_clip_ids or []
# 将封面候选帧写入 plan.config(供封面 API 直接使用,跳过 MediaKit 抽帧)
if result.cover_candidates:
plan_config = plan.config or {}
plan_config["cover_candidates"] = result.cover_candidates
plan.config = plan_config
logger.info(
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
plan_id, len(result.cover_candidates),
)
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
+146 -65
View File
@@ -354,6 +354,81 @@ def _transfer_cover_frame_to_storage(frame_url: str, plan_id: str) -> str:
return frame_url
def _extract_frames_with_ffmpeg(
video_url: str,
num_frames: int = 3,
timeout: int = 30,
) -> list[dict]:
"""用 FFmpeg 从远程视频 URL 流式 seek 抽帧(HTTP range request,不下载整个视频)。
Args:
video_url: 视频 URL
num_frames: 抽帧数量
timeout: 单帧超时(秒)
Returns:
[{"local_path": "...", "frame_time": 5.0}, ...]
"""
import re as _re
import tempfile
from pathlib import Path as _Path
from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
video_url = _re.sub(r"(?<!:)//", "/", video_url)
# 先用 ffprobe 获取视频时长
import subprocess as _subprocess
from packages.shared.ffmpeg_utils import FFPROBE_BIN
duration = 30.0 # 默认假设 30 秒
try:
probe_result = _subprocess.run(
[
FFPROBE_BIN, "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_url,
],
capture_output=True, text=True, timeout=15,
)
if probe_result.returncode == 0 and probe_result.stdout.strip():
duration = float(probe_result.stdout.strip())
except Exception as e:
logger.warning("FFprobe 远程视频时长失败,使用默认值: %s", e)
ratios = [i / (num_frames + 1) for i in range(1, num_frames + 1)]
results = []
for idx, ratio in enumerate(ratios):
frame_time = max(0.5, duration * ratio)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
output_path = tmp.name
try:
seek_str = f"{int(frame_time // 3600):02d}:{int((frame_time % 3600) // 60):02d}:{frame_time % 60:05.2f}"
cmd = [
FFMPEG_BIN, "-y",
"-ss", seek_str,
"-i", video_url,
"-vframes", "1",
"-q:v", "2",
output_path,
]
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
if _Path(output_path).exists() and _Path(output_path).stat().st_size > 0:
results.append({"local_path": output_path, "frame_time": round(frame_time, 2)})
else:
_Path(output_path).unlink(missing_ok=True)
except Exception as e:
logger.warning("FFmpeg 远程抽帧失败 ratio=%.2f: %s", ratio, e)
_Path(output_path).unlink(missing_ok=True)
return results
def _call_ai_cover_service(
plan_id: str,
asset_ids: List[str],
@@ -363,7 +438,10 @@ def _call_ai_cover_service(
) -> Dict[str, Any]:
"""调用 AI 封面生成服务.
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
优先级:
1. 检查 plan.config 中的 cover_candidates(渲染时预抽帧)——由调用方处理
2. FFmpeg 本地从 URL 流式 seek 抽帧(HTTP range request,不下载整个视频)
失败时抛出 RuntimeError。
Args:
@@ -371,7 +449,7 @@ def _call_ai_cover_service(
asset_ids: 素材 ID 列表
cover_type: 封面类型
frame_time: 手动选帧时间点
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
primary_video_url: 主视频的可访问 URL
"""
if cover_type == "upload":
return {
@@ -394,79 +472,82 @@ def _call_ai_cover_service(
"frame_time": frame_time,
}
# ai_frame / ai_regenerate - 尝试调用 MediaKit
# ai_frame / ai_regenerate - 使用 FFmpeg 本地抽帧
if primary_video_url:
# 规范化 URL:合并路径中的双斜杠(保留协议头 ://)
# 历史数据中 project_id 为空时 OSS key 会出现 projects//tasks/ 路径
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
from packages.shared.mediakit_client import get_mediakit_client
client = get_mediakit_client()
if client.is_available:
# 先检查视频 URL 是否可访问,避免 MediaKit 下载失败后超时
try:
head_resp = http_requests.head(primary_video_url, timeout=10, allow_redirects=True)
if head_resp.status_code != 200:
logger.error(
"封面视频URL不可访问: plan_id=%s url=%s status=%d",
plan_id,
primary_video_url,
head_resp.status_code,
)
raise RuntimeError(
f"封面生成失败: 预览视频URL不可访问 (HTTP {head_resp.status_code})。"
f"请重新生成预览视频后再试。"
)
except http_requests.RequestException as e:
logger.error("封面视频URL连通性检查失败: plan_id=%s url=%s error=%s", plan_id, primary_video_url, e)
raise RuntimeError(
f"封面生成失败: 无法访问预览视频 ({e.__class__.__name__})。" f"请重新生成预览视频后再试。"
) from e
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="TimeInterval", # 按固定时间间隔,稳定性好,不易 OOM
max_frames=3, # 减少到 3 帧,平衡速度和质量
poll_interval=2.0, # 缩短轮询间隔
max_poll_attempts=60, # 120秒超时
# 先检查视频 URL 是否可访问
try:
head_resp = http_requests.head(primary_video_url, timeout=10, allow_redirects=True)
if head_resp.status_code != 200:
logger.error(
"封面视频URL不可访问: plan_id=%s url=%s status=%d",
plan_id, primary_video_url, head_resp.status_code,
)
raise RuntimeError(
f"封面生成失败: 预览视频URL不可访问 (HTTP {head_resp.status_code})。"
f"请重新生成预览视频后再试。"
)
except http_requests.RequestException as e:
logger.error("封面视频URL连通性检查失败: plan_id=%s url=%s error=%s", plan_id, primary_video_url, e)
raise RuntimeError(
f"封面生成失败: 无法访问预览视频 ({e.__class__.__name__})。请重新生成预览视频后再试。"
) from e
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)
# 使用 FFmpeg 从 URL 流式 seek 抽帧
try:
logger.info("FFmpeg 远程抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
frames = _extract_frames_with_ffmpeg(primary_video_url, num_frames=3)
if image_url:
logger.info(
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id,
timestamp,
image_url[:80],
)
# MediaKit 返回的 URL 是临时内部 URL,浏览器无法直接访问
# 需要下载到本地并重新上传到 OSS,返回公开可访问的 URL
public_url = _transfer_cover_frame_to_storage(image_url, plan_id)
return {
"type": "ai_frame",
"image_url": public_url,
"frame_time": round(timestamp, 1),
"confidence": 0.85,
}
else:
logger.warning("MediaKit 返回的帧无 image_url")
if frames:
best_frame = frames[0]
local_path = best_frame["local_path"]
frame_time_val = best_frame["frame_time"]
except Exception as e:
logger.exception("MediaKit 抽帧失败: %s", str(e))
# 上传到 OSS
try:
import uuid
from pathlib import Path
# 封面生成失败 - 不再降级到 stub,直接报错
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/ffmpeg_frame_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
file_or_path=local_path,
storage_key=cover_key,
content_type="image/jpeg",
)
public_url = storage.get_url(cover_key)
logger.info(
"FFmpeg 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id, frame_time_val, public_url[:80],
)
return {
"type": "ai_frame",
"image_url": public_url,
"frame_time": round(frame_time_val, 1),
"confidence": 0.85,
}
finally:
# 清理所有临时文件
for frame in frames:
try:
Path(frame["local_path"]).unlink(missing_ok=True)
except Exception:
pass
except RuntimeError:
raise
except Exception as e:
logger.exception("FFmpeg 远程抽帧失败: %s", str(e))
# 封面生成失败
raise RuntimeError(
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
f"封面生成失败: plan_id={plan_id},无法从视频抽帧。请检查 primary_video_url 是否可访问。"
)
+342
View File
@@ -0,0 +1,342 @@
"""Tests for cover frame pre-extraction during rendering.
Tests:
- extract_cover_candidates: FFmpeg frame extraction at 25%/50%/75%
- extract_and_upload_cover_frames: extraction + OSS upload
- RenderAdapterResult.cover_candidates field
- generation_cover route uses pre-stored candidates
- ai_service FFmpeg fallback
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, Mock, call, patch
import pytest
class TestExtractCoverCandidates:
"""extract_cover_candidates 测试."""
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=20.0)
def test_extracts_3_frames_at_correct_positions(self, mock_probe, mock_run):
"""在 25%/50%/75% 处抽取 3 帧."""
import tempfile
from video_processing.thumbnail_generator import extract_cover_candidates
# Create temp files that look like they were created
def fake_run(cmd, **kwargs):
# Find the output path (last arg)
output_path = cmd[-1]
Path(output_path).write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
return ("", "")
mock_run.side_effect = fake_run
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(b"fake video")
video_path = tmp.name
try:
results = extract_cover_candidates(video_path, num_frames=3)
assert len(results) == 3
# Check frame times: 20*0.25=5.0, 20*0.5=10.0, 20*0.75=15.0
assert results[0]["frame_time"] == 5.0
assert results[1]["frame_time"] == 10.0
assert results[2]["frame_time"] == 15.0
# Check local paths exist
for r in results:
assert Path(r["local_path"]).exists()
# Clean up
for r in results:
Path(r["local_path"]).unlink(missing_ok=True)
finally:
Path(video_path).unlink(missing_ok=True)
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=20.0)
def test_handles_ffmpeg_failure_gracefully(self, mock_probe, mock_run):
"""FFmpeg 失败时跳过该帧,继续抽取其他帧."""
import tempfile
from video_processing.thumbnail_generator import extract_cover_candidates
call_count = 0
def fake_run(cmd, **kwargs):
nonlocal call_count
call_count += 1
output_path = cmd[-1]
if call_count == 2:
# Second frame fails - don't create file
raise RuntimeError("ffmpeg error")
Path(output_path).write_bytes(b"\xff\xd8" + b"\x00" * 50)
return ("", "")
mock_run.side_effect = fake_run
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(b"fake video")
video_path = tmp.name
try:
results = extract_cover_candidates(video_path, num_frames=3)
# Should get 2 frames (1st and 3rd), 2nd failed
assert len(results) == 2
finally:
Path(video_path).unlink(missing_ok=True)
for r in results:
Path(r["local_path"]).unlink(missing_ok=True)
@patch("video_processing.ffmpeg_utils.probe_duration", side_effect=Exception("probe failed"))
def test_fallback_duration_when_probe_fails(self, mock_probe):
"""probe 失败时使用默认时长."""
import tempfile
from video_processing.thumbnail_generator import extract_cover_candidates
# Mock run_ffmpeg to create output files
def fake_run(cmd, **kwargs):
output_path = cmd[-1]
Path(output_path).write_bytes(b"\xff\xd8" + b"\x00" * 50)
return ("", "")
with patch("video_processing.ffmpeg_utils.run_ffmpeg", side_effect=fake_run):
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(b"fake")
video_path = tmp.name
try:
results = extract_cover_candidates(video_path, num_frames=3)
assert len(results) == 3
# Default duration is 5.0, so times should be 5*0.25=1.25, 5*0.5=2.5, 5*0.75=3.75
assert results[0]["frame_time"] == 1.25
assert results[1]["frame_time"] == 2.5
assert results[2]["frame_time"] == 3.75
finally:
Path(video_path).unlink(missing_ok=True)
for r in results:
Path(r["local_path"]).unlink(missing_ok=True)
class TestExtractAndUploadCoverFrames:
"""extract_and_upload_cover_frames 测试."""
@patch("video_processing.oss_helpers.upload_to_oss")
@patch("video_processing.thumbnail_generator.extract_cover_candidates")
def test_uploads_and_returns_correct_format(self, mock_extract, mock_upload):
"""上传帧到 OSS 并返回正确格式."""
import tempfile
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
# Create actual temp files
tmp1 = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp1.write(b"\xff\xd8" + b"\x00" * 50)
tmp1.close()
tmp2 = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp2.write(b"\xff\xd8" + b"\x00" * 50)
tmp2.close()
mock_extract.return_value = [
{"local_path": tmp1.name, "frame_time": 5.0},
{"local_path": tmp2.name, "frame_time": 10.0},
]
mock_upload.side_effect = [
"https://oss.example.com/covers/plan1/frame_0.jpg",
"https://oss.example.com/covers/plan1/frame_1.jpg",
]
results = extract_and_upload_cover_frames("/tmp/video.mp4", "plan1")
assert len(results) == 2
assert results[0]["image_url"] == "https://oss.example.com/covers/plan1/frame_0.jpg"
assert results[0]["frame_time"] == 5.0
assert results[0]["storage_key"] == "covers/plan1/frame_0.jpg"
assert results[1]["image_url"] == "https://oss.example.com/covers/plan1/frame_1.jpg"
assert results[1]["frame_time"] == 10.0
@patch("video_processing.thumbnail_generator.extract_cover_candidates", return_value=[])
def test_returns_empty_when_no_candidates(self, mock_extract):
"""没有候选帧时返回空列表."""
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
results = extract_and_upload_cover_frames("/tmp/video.mp4", "plan1")
assert results == []
@patch("video_processing.oss_helpers.upload_to_oss", side_effect=Exception("OSS error"))
@patch("video_processing.thumbnail_generator.extract_cover_candidates")
def test_handles_upload_failure_gracefully(self, mock_extract, mock_upload):
"""上传失败时跳过该帧."""
import tempfile
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
tmp1 = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp1.write(b"\xff\xd8" + b"\x00" * 50)
tmp1.close()
mock_extract.return_value = [
{"local_path": tmp1.name, "frame_time": 5.0},
]
results = extract_and_upload_cover_frames("/tmp/video.mp4", "plan1")
assert results == []
class TestRenderAdapterResultCoverCandidates:
"""RenderAdapterResult 的 cover_candidates 字段."""
def test_default_none(self):
"""默认为 None."""
from video_processing.render_adapter import RenderAdapterResult
result = RenderAdapterResult(success=True)
assert result.cover_candidates is None
def test_can_set_candidates(self):
"""可以设置候选帧列表."""
from video_processing.render_adapter import RenderAdapterResult
candidates = [
{"image_url": "https://example.com/frame_0.jpg", "frame_time": 5.0, "storage_key": "covers/p1/frame_0.jpg"},
]
result = RenderAdapterResult(success=True, cover_candidates=candidates)
assert len(result.cover_candidates) == 1
assert result.cover_candidates[0]["frame_time"] == 5.0
class TestAICoverServiceFFmpegFallback:
"""AI 封面服务 FFmpeg 兜底测试."""
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_ffmpeg_fallback_success(self, mock_ffmpeg, mock_head):
"""FFmpeg 兜底抽帧成功."""
import tempfile
mock_head.return_value.status_code = 200
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(b"\xff\xd8" + b"\x00" * 50)
tmp.close()
mock_ffmpeg.return_value = [{"local_path": tmp.name, "frame_time": 5.0}]
# Mock storage
with patch("packages.shared.storage.get_shared_storage_service") as mock_storage_fn:
mock_storage = Mock()
mock_storage.upload_file = Mock()
mock_storage.get_url.return_value = "https://oss.example.com/covers/plan1/ffmpeg_frame_abc.jpg"
mock_storage_fn.return_value = mock_storage
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "ai_frame"
assert result["image_url"] == "https://oss.example.com/covers/plan1/ffmpeg_frame_abc.jpg"
assert result["frame_time"] == 5.0
assert result["confidence"] == 0.85
Path(tmp.name).unlink(missing_ok=True)
@patch("packages.shared.ai_service.http_requests.head")
def test_ffmpeg_no_video_url_raises(self, mock_head):
"""没有视频 URL 时抛出 RuntimeError."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url=None,
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_ffmpeg_no_frames_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 抽帧为空时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
def test_upload_type_returns_immediately(self):
"""upload 类型直接返回."""
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="upload",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "upload"
def test_manual_type_returns_immediately(self):
"""manual 类型直接返回."""
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
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.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_video_url_unreachable_raises(self, mock_ffmpeg, mock_head):
"""视频 URL 不可访问时抛出 RuntimeError."""
mock_head.return_value.status_code = 404
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="预览视频URL不可访问"):
_call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
class TestCoverTemplatesFix:
"""CoverTemplateResponse config=None 修复测试."""
def test_config_none_becomes_empty_dict(self):
"""config=None 时 CoverTemplateResponse 不报 ValidationError."""
from datetime import datetime
from app.schemas.cover_template import CoverTemplateResponse
# This should not raise
resp = CoverTemplateResponse(
id="1",
name="test",
thumbnail_url="",
is_system=True,
created_at=datetime.now(),
config={},
)
assert resp.config == {}
+48 -75
View File
@@ -3,6 +3,7 @@
测试 #1208: AI封面接入MediaKit视频截帧
"""
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -140,47 +141,49 @@ class TestMediaKitClient:
class TestAICoverService:
"""AI 封面服务测试."""
"""AI 封面服务测试(已迁移到 FFmpeg 本地抽帧)。"""
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_with_mediakit_success(self, mock_get_client, mock_head):
"""MediaKit 抽帧成功."""
# Mock HEAD request to verify URL is accessible
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_with_ffmpeg_success(self, mock_ffmpeg, mock_head):
"""FFmpeg 本地抽帧成功."""
import tempfile
mock_head.return_value.status_code = 200
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
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(b"\xff\xd8" + b"\x00" * 50)
tmp.close()
from packages.shared.ai_service import _call_ai_cover_service
mock_ffmpeg.return_value = [{"local_path": tmp.name, "frame_time": 3.5}]
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",
)
with patch("packages.shared.storage.get_shared_storage_service") as mock_storage_fn:
mock_storage = Mock()
mock_storage.upload_file = Mock()
mock_storage.get_url.return_value = "https://example.com/frame.jpg"
mock_storage_fn.return_value = mock_storage
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
from packages.shared.ai_service import _call_ai_cover_service
mock_client.extract_frames.assert_called_once()
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
Path(tmp.name).unlink(missing_ok=True)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_video_url_unreachable(self, mock_get_client, mock_head):
def test_call_ai_cover_video_url_unreachable(self, mock_head):
"""视频 URL 不可访问时抛出 RuntimeError."""
# Mock HEAD request to return 404
mock_head.return_value.status_code = 404
mock_client = Mock()
mock_client.is_available = True
mock_get_client.return_value = mock_client
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="预览视频URL不可访问"):
@@ -192,17 +195,14 @@ class TestAICoverService:
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_url_double_slash_normalized(self, mock_get_client, mock_head):
"""URL 路径中的双斜杠应被规范化,避免 MediaKit 404."""
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_url_double_slash_normalized(self, mock_ffmpeg, mock_head):
"""URL 路径中的双斜杠应被规范化."""
dirty_url = "https://oss.example.com/generated/projects//tasks/abc123/rendered.mp4"
clean_url = "https://oss.example.com/generated/projects/tasks/abc123/rendered.mp4"
mock_head.return_value.status_code = 200
mock_client = Mock()
mock_client.is_available = True
mock_client.extract_frames.return_value = []
mock_get_client.return_value = mock_client
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
@@ -219,20 +219,15 @@ class TestAICoverService:
assert mock_head.call_args[0][0] == clean_url
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_with_mediakit_failure_raises(self, mock_get_client, mock_head):
"""MediaKit 失败时抛出 RuntimeError(不再降级到 stub."""
# Mock HEAD request to return 200 (URL is accessible, but MediaKit fails)
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_ffmpeg_failure_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 抽帧失败时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_client = Mock()
mock_client.is_available = True
mock_client.extract_frames.side_effect = Exception("API error")
mock_get_client.return_value = mock_client
mock_ffmpeg.side_effect = Exception("ffmpeg error")
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
@@ -241,11 +236,10 @@ class TestAICoverService:
)
def test_call_ai_cover_without_video_url_raises(self):
"""没有视频 URL 时抛出 RuntimeError(不再降级到 stub."""
"""没有视频 URL 时抛出 RuntimeError."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
@@ -282,37 +276,16 @@ class TestAICoverService:
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_raises(self, mock_get_client):
"""MediaKit 未配置时抛出 RuntimeError(不再降级到 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
with pytest.raises(RuntimeError, match="MediaKit"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_empty_frames_raises(self, mock_get_client, mock_head):
"""MediaKit 返回空帧列表时抛出 RuntimeError(不再降级)."""
mock_head.return_value.status_code = 200 # URL accessible
mock_client = Mock()
mock_client.is_available = True
mock_client.extract_frames.return_value = []
mock_get_client.return_value = mock_client
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_empty_frames_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 返回空帧列表时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
+2 -2
View File
@@ -436,12 +436,12 @@ class TestAiCoverService:
def test_cover_type_ai_frame_raises_without_mediakit(self):
"""ai_frame mode raises RuntimeError when MediaKit is unavailable."""
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service("plan1", ["a1"], "ai_frame")
def test_cover_type_ai_regenerate_raises_without_mediakit(self):
"""ai_regenerate mode raises RuntimeError when MediaKit is unavailable."""
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
def test_cover_type_manual_still_works(self):