Files
xiaoxia-saas/tests/unit/test_thumbnail_generator.py
xiaoxia ecbdd49dc1
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m13s
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 / Validate - Migration (alembic) (pull_request) Successful in 2m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m24s
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 / PR Build Worker Image (pull_request) Successful in 38s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
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 / PR Build API Image (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
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Unit Tests (push) Failing after 12m24s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat: 素材片段随机起始时间+去重 + MediaKit智能封面抽帧
## 任务1: 素材片段随机起始时间
- 修改 _calc_random_start_time 增加 used_segments 参数,避开已使用时间段
- 四个分配函数(ONE_TAKE/PIP/VOICE_OVER/VOICE_PIP)各自维护 used_segments
- 同一素材被多个clip使用时,自动记录已用区间,下次随机时避开
- 如果无空间可用,回退到最后已用段之后或缩短时长

## 任务2: MediaKit智能封面抽帧
- 新增 _extract_frames_via_mediakit 函数,上传视频到OSS后调用MediaKit API
- 使用 SceneChange 策略智能选取关键帧
- 失败时自动降级到 ffmpeg 均匀抽帧
- 抽帧后下载帧图、可选叠加标题、上传OSS

## 测试
- 新增 8 个 _calc_random_start_time 单测
- 新增 4 个 distribute_assets 去重逻辑单测
- 新增 4 个 MediaKit 集成单测
- 修复 3 个因字体映射变更的测试断言
2026-08-26 17:22:08 +08:00

224 lines
7.2 KiB
Python
Executable File

"""缩略图生成器单元测试 - 纯逻辑函数."""
from __future__ import annotations
import pytest
from video_processing.thumbnail_generator import _format_seek_time
class TestFormatSeekTime:
"""_format_seek_time 时间格式化测试."""
def test_zero_seconds(self):
"""0秒."""
result = _format_seek_time(0)
assert result == "00:00:00.00"
def test_less_than_one_second(self):
"""小于1秒."""
result = _format_seek_time(0.5)
assert result == "00:00:00.50"
def test_few_seconds(self):
"""几秒."""
result = _format_seek_time(5.5)
assert result == "00:00:05.50"
def test_one_minute(self):
"""1分钟."""
result = _format_seek_time(60.0)
assert result == "00:01:00.00"
def test_minutes_and_seconds(self):
"""分+秒."""
result = _format_seek_time(125.5)
assert result == "00:02:05.50"
def test_one_hour(self):
"""1小时."""
result = _format_seek_time(3600.0)
assert result == "01:00:00.00"
def test_hours_minutes_seconds(self):
"""时+分+秒."""
result = _format_seek_time(3725.25)
assert result == "01:02:05.25"
def test_long_duration(self):
"""长视频(2小时以上)."""
result = _format_seek_time(7384.12)
assert result == "02:03:04.12"
def test_precision_two_decimal(self):
"""两位小数精度."""
result = _format_seek_time(3.14159)
assert result == "00:00:03.14"
def test_always_two_digit_hours(self):
"""小时始终两位数字."""
result = _format_seek_time(3600 * 9)
assert result.startswith("09:")
def test_always_two_digit_minutes(self):
"""分钟始终两位数字."""
result = _format_seek_time(300) # 5分钟
parts = result.split(":")
assert parts[1] == "05"
def test_float_input(self):
"""浮点数输入."""
result = _format_seek_time(10.0)
assert isinstance(result, str)
assert result == "00:00:10.00"
def test_int_input(self):
"""整数输入."""
result = _format_seek_time(30)
assert result == "00:00:30.00"
def test_format_structure(self):
"""格式结构正确:HH:MM:SS.xx."""
result = _format_seek_time(3661.5)
# 格式: HH:MM:SS.xx
parts = result.split(":")
assert len(parts) == 3
assert "." in parts[2]
sec_parts = parts[2].split(".")
assert len(sec_parts) == 2
assert len(sec_parts[1]) == 2 # 两位小数
# ── MediaKit 智能抽帧集成测试 ──────────────────────────────────────────
class TestExtractFramesViaMediakit:
"""_extract_frames_via_mediakit 函数测试."""
def test_mediakit_not_configured_returns_none(self, tmp_path, monkeypatch):
"""MediaKit 未配置时返回 None."""
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
# Mock get_mediakit_client 返回不可用客户端
class FakeClient:
is_available = False
monkeypatch.setattr(
"packages.shared.mediakit_client.get_mediakit_client",
lambda: FakeClient(),
)
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake video")
result = _extract_frames_via_mediakit(str(video_file), "plan1", 3)
assert result is None
def test_mediakit_success_returns_frames(self, tmp_path, monkeypatch):
"""MediaKit 成功时返回帧列表."""
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
class FakeClient:
is_available = True
def extract_frames(self, video_url, strategy, max_frames):
return [
{"image_url": "https://example.com/frame1.jpg", "timestamp": 1.5},
{"image_url": "https://example.com/frame2.jpg", "timestamp": 3.2},
]
monkeypatch.setattr(
"packages.shared.mediakit_client.get_mediakit_client",
lambda: FakeClient(),
)
# Mock upload_to_oss
monkeypatch.setattr(
"video_processing.oss_helpers.upload_to_oss",
lambda path, key: f"https://oss.example.com/{key}",
)
# Mock httpx.get for downloading frame
import httpx
class FakeResponse:
status_code = 200
content = b"fake image data"
def raise_for_status(self):
pass
monkeypatch.setattr("httpx.get", lambda url, **kw: FakeResponse())
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake video")
result = _extract_frames_via_mediakit(str(video_file), "plan1", 2)
assert result is not None
assert len(result) == 2
assert result[0]["timestamp"] == 1.5
assert result[1]["timestamp"] == 3.2
def test_mediakit_failure_returns_none(self, tmp_path, monkeypatch):
"""MediaKit 调用失败时返回 None."""
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
class FakeClient:
is_available = True
def extract_frames(self, video_url, strategy, max_frames):
return None
monkeypatch.setattr(
"packages.shared.mediakit_client.get_mediakit_client",
lambda: FakeClient(),
)
monkeypatch.setattr(
"video_processing.oss_helpers.upload_to_oss",
lambda path, key: f"https://oss.example.com/{key}",
)
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake video")
result = _extract_frames_via_mediakit(str(video_file), "plan1", 3)
assert result is None
class TestExtractAndUploadCoverFramesFallback:
"""extract_and_upload_cover_frames 降级逻辑测试."""
def test_fallback_to_ffmpeg_when_mediakit_fails(self, tmp_path, monkeypatch):
"""MediaKit 失败时降级到 ffmpeg 抽帧."""
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
# Mock MediaKit 返回 None(未配置或失败)
class FakeClient:
is_available = False
monkeypatch.setattr(
"packages.shared.mediakit_client.get_mediakit_client",
lambda: FakeClient(),
)
# Mock ffmpeg 抽帧
monkeypatch.setattr(
"video_processing.thumbnail_generator.extract_first_frame",
lambda video_path, output_path, **kw: output_path,
)
# Mock upload
monkeypatch.setattr(
"video_processing.oss_helpers.upload_to_oss",
lambda path, key: f"https://oss.example.com/{key}",
)
# Mock probe_duration
monkeypatch.setattr(
"video_processing.ffmpeg_utils.probe_duration",
lambda path: 60.0,
)
video_file = tmp_path / "test.mp4"
video_file.write_bytes(b"fake video")
result = extract_and_upload_cover_frames(str(video_file), "plan1", num_frames=2)
assert len(result) == 2
assert all("url" in item for item in result)
assert all("position" in item for item in result)