25a98c33b9
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
258 lines
9.6 KiB
Python
258 lines
9.6 KiB
Python
"""Tests for scene-change smart frame selection + random shuffle of segment processing."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from apps.api.app.api.routes.templates_editor.clips import (
|
||
_build_scene_segments,
|
||
_pick_start_in_scene_segment,
|
||
)
|
||
from packages.shared.mediakit_client import MediaKitClient
|
||
|
||
# ── Part 1: Random shuffle tests ──────────────────────────────────────────
|
||
|
||
|
||
class TestRandomShuffle:
|
||
"""验证 segments 处理顺序随机打乱逻辑."""
|
||
|
||
def test_same_segments_produce_different_asset_orders(self):
|
||
"""同一批 segments 多次处理,asset 分配顺序有变化.
|
||
|
||
模拟打乱后的处理顺序,验证多次运行中 asset_id 分配顺序
|
||
存在差异(概率性验证,运行 50 次应该至少出现 2 种排列)。
|
||
"""
|
||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||
asset_ids = ["A", "B", "C", "D"]
|
||
|
||
observed_orders: list[tuple] = set()
|
||
|
||
for _ in range(50):
|
||
shuffled_indices = list(range(len(segments)))
|
||
random.shuffle(shuffled_indices)
|
||
order_tuple = tuple(shuffled_indices)
|
||
observed_orders.add(order_tuple)
|
||
|
||
# 50 次打乱,4! = 24 种排列,应出现多种不同排列
|
||
assert len(observed_orders) > 1, "打乱应该产生多种不同顺序"
|
||
|
||
def test_clips_data_order_always_sorted(self):
|
||
"""clips_data 按 order 排序后始终有序.
|
||
|
||
模拟打乱处理后 clips_data 按 order 排序,验证最终 order 为 [0,1,2,3]。
|
||
"""
|
||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||
|
||
for _ in range(20):
|
||
shuffled_indices = list(range(len(segments)))
|
||
random.shuffle(shuffled_indices)
|
||
|
||
# 模拟构建 clips_data(用 _seg_order 作为 order)
|
||
clips_data = []
|
||
for idx in shuffled_indices:
|
||
seg_order, _, _ = segments[idx]
|
||
clips_data.append({"order": seg_order, "asset_id": f"asset_{idx}"})
|
||
|
||
# 按 order 排序
|
||
clips_data.sort(key=lambda c: c["order"])
|
||
|
||
# 验证 order 始终有序
|
||
orders = [c["order"] for c in clips_data]
|
||
assert orders == [0, 1, 2, 3], f"排序后 order 应为 [0,1,2,3],实际为 {orders}"
|
||
|
||
|
||
# ── Part 2: detect_scene_changes tests ────────────────────────────────────
|
||
|
||
|
||
class TestDetectSceneChanges:
|
||
"""验证 MediaKitClient.detect_scene_changes 方法."""
|
||
|
||
def _make_client(self) -> MediaKitClient:
|
||
"""创建一个可用的 MediaKitClient(mock 配置)."""
|
||
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 = "http://test"
|
||
mock_settings.return_value.mediakit_timeout = 30
|
||
client = MediaKitClient()
|
||
return client
|
||
|
||
def test_scene_change_success(self):
|
||
"""SceneChange 策略成功返回时间戳列表."""
|
||
client = self._make_client()
|
||
|
||
mock_frames = [
|
||
{"image_url": "url1", "timestamp": 0.0},
|
||
{"image_url": "url2", "timestamp": 3.2},
|
||
{"image_url": "url3", "timestamp": 7.8},
|
||
{"image_url": "url4", "timestamp": 12.5},
|
||
]
|
||
|
||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||
|
||
assert result is not None
|
||
assert result[0] == 0.0 # 始终以 0.0 开头
|
||
assert 3.2 in result
|
||
assert 7.8 in result
|
||
assert 12.5 in result
|
||
assert result == sorted(result) # 应已排序
|
||
|
||
def test_scene_change_fallback_to_time_interval(self):
|
||
"""SceneChange 失败降级到 TimeInterval 策略."""
|
||
client = self._make_client()
|
||
|
||
# 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果
|
||
fallback_frames = [
|
||
{"image_url": "url1", "timestamp": 0.0},
|
||
{"image_url": "url2", "timestamp": 5.0},
|
||
{"image_url": "url3", "timestamp": 10.0},
|
||
]
|
||
|
||
call_count = 0
|
||
|
||
def side_effect(*args, **kwargs):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count == 1:
|
||
# 第一次 SceneChange 失败
|
||
return None
|
||
else:
|
||
# 第二次 TimeInterval 成功
|
||
assert kwargs.get("strategy") == "TimeInterval"
|
||
return fallback_frames
|
||
|
||
with patch.object(client, "extract_frames", side_effect=side_effect):
|
||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||
|
||
assert result is not None
|
||
assert result[0] == 0.0
|
||
assert 5.0 in result
|
||
assert 10.0 in result
|
||
|
||
def test_mediakit_not_available_returns_none(self):
|
||
"""MediaKit 不可用时返回 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 = "http://test"
|
||
mock_settings.return_value.mediakit_timeout = 30
|
||
client = MediaKitClient()
|
||
|
||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||
assert result is None
|
||
|
||
def test_both_strategies_fail_returns_none(self):
|
||
"""SceneChange 和 TimeInterval 都失败时返回 None."""
|
||
client = self._make_client()
|
||
|
||
with patch.object(client, "extract_frames", return_value=None):
|
||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||
|
||
assert result is None
|
||
|
||
def test_prepends_zero_if_not_present(self):
|
||
"""若帧列表中不包含 0.0,自动在开头添加."""
|
||
client = self._make_client()
|
||
|
||
# 帧列表中没有 timestamp=0.0
|
||
mock_frames = [
|
||
{"image_url": "url1", "timestamp": 2.0},
|
||
{"image_url": "url2", "timestamp": 5.5},
|
||
]
|
||
|
||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||
|
||
assert result is not None
|
||
assert result[0] == 0.0
|
||
assert 2.0 in result
|
||
assert 5.5 in result
|
||
|
||
|
||
# ── Part 2.2: Scene segment building and assignment ───────────────────────
|
||
|
||
|
||
class TestSceneSegments:
|
||
"""验证镜头段构建和分配逻辑."""
|
||
|
||
def test_build_scene_segments(self):
|
||
"""从场景切换点正确构建镜头段."""
|
||
scene_changes = [0.0, 3.2, 7.8, 12.5]
|
||
asset_duration = 15.0
|
||
|
||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||
|
||
assert len(segments) == 4
|
||
assert segments[0] == (0.0, 3.2)
|
||
assert segments[1] == (3.2, 7.8)
|
||
assert segments[2] == (7.8, 12.5)
|
||
assert segments[3] == (12.5, 15.0)
|
||
|
||
def test_build_scene_segments_filters_short(self):
|
||
"""过滤掉过短的镜头段(< 0.5秒)."""
|
||
scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0]
|
||
asset_duration = 12.0
|
||
|
||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||
|
||
# (0.0, 0.1) 长度 0.1 < 0.5 → 过滤
|
||
# (0.1, 5.0) → 保留
|
||
# (5.0, 5.3) 长度 0.3 < 0.5 → 过滤
|
||
# (5.3, 10.0) → 保留
|
||
# (10.0, 12.0) → 保留
|
||
assert len(segments) == 3
|
||
assert segments[0] == (0.1, 5.0)
|
||
assert segments[1] == (5.3, 10.0)
|
||
assert segments[2] == (10.0, 12.0)
|
||
|
||
def test_pick_start_in_segment(self):
|
||
"""在镜头段内随机选取起始时间."""
|
||
seg_start = 3.0
|
||
seg_end = 8.0
|
||
clip_duration = 2.0
|
||
|
||
starts = set()
|
||
for _ in range(100):
|
||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||
assert start is not None
|
||
assert seg_start <= start <= seg_end - clip_duration
|
||
starts.add(round(start, 2))
|
||
|
||
# 应该有多个不同的起始时间
|
||
assert len(starts) > 1
|
||
|
||
def test_pick_start_segment_too_short(self):
|
||
"""镜头段太短无法容纳片段时返回 None."""
|
||
result = _pick_start_in_scene_segment(0.0, 1.0, 2.0)
|
||
assert result is None
|
||
|
||
def test_different_clips_from_different_scenes(self):
|
||
"""不同片段应来自不同的镜头段(模拟分配逻辑)."""
|
||
scene_changes = [0.0, 5.0, 10.0, 15.0]
|
||
asset_duration = 18.0
|
||
clip_duration = 3.0
|
||
|
||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||
assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18)
|
||
|
||
# 模拟 3 个片段从不同镜头段取点
|
||
scene_pool = list(segments)
|
||
assigned_starts = []
|
||
|
||
for _ in range(3):
|
||
if not scene_pool:
|
||
break
|
||
seg_start, seg_end = scene_pool.pop(0)
|
||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||
assert start is not None
|
||
assigned_starts.append(start)
|
||
|
||
# 3 个片段分别从 3 个不同镜头段中选取
|
||
assert len(assigned_starts) == 3
|
||
# 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12]
|
||
assert 0.0 <= assigned_starts[0] <= 2.0
|
||
assert 5.0 <= assigned_starts[1] <= 7.0
|
||
assert 10.0 <= assigned_starts[2] <= 12.0
|