Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a98c33b9 | |||
| 8920bead38 | |||
| 827d8aafe5 |
@@ -724,7 +724,12 @@ def create_clips_from_assets_editor(
|
||||
else:
|
||||
transition_compensation = 0.0
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 打乱 segments 的处理顺序(分配素材的顺序随机化),但最终 clips_data 按原始 order 排序
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
|
||||
for idx in shuffled_indices:
|
||||
_seg_order, dur_min, dur_max = segments[idx]
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
|
||||
@@ -805,7 +810,7 @@ def create_clips_from_assets_editor(
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": i,
|
||||
"order": _seg_order,
|
||||
"asset_id": asset_id,
|
||||
"start_time": start_time,
|
||||
"duration": clip_duration,
|
||||
@@ -813,6 +818,9 @@ def create_clips_from_assets_editor(
|
||||
}
|
||||
)
|
||||
|
||||
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...)
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
|
||||
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
@@ -860,11 +868,58 @@ def create_clips_from_assets_editor(
|
||||
)
|
||||
|
||||
|
||||
def _build_scene_segments(
|
||||
scene_changes: list[float],
|
||||
asset_duration: float,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""根据场景切换点构建镜头段列表.
|
||||
|
||||
Args:
|
||||
scene_changes: 场景切换点时间戳列表(已排序,首位为 0.0)
|
||||
asset_duration: 素材总时长
|
||||
|
||||
Returns:
|
||||
镜头段列表 [(start, end), ...]
|
||||
"""
|
||||
segments: list[tuple[float, float]] = []
|
||||
for i, ts in enumerate(scene_changes):
|
||||
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
|
||||
# 只保留有效长度的镜头段(至少 0.5 秒)
|
||||
if end - ts >= 0.5:
|
||||
segments.append((ts, end))
|
||||
return segments
|
||||
|
||||
|
||||
def _pick_start_in_scene_segment(
|
||||
seg_start: float,
|
||||
seg_end: float,
|
||||
clip_duration: float,
|
||||
) -> float | None:
|
||||
"""在镜头段内随机选取一个起始时间点.
|
||||
|
||||
确保 start + clip_duration <= seg_end。
|
||||
若镜头段长度不足以容纳片段,返回 None。
|
||||
"""
|
||||
available = seg_end - seg_start - clip_duration
|
||||
if available < 0:
|
||||
return None
|
||||
max_start = seg_start + available
|
||||
return random.uniform(seg_start, max_start)
|
||||
|
||||
|
||||
def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
) -> None:
|
||||
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
|
||||
"""后台任务:使用 SceneChange 智能选帧并更新片段的起始时间.
|
||||
|
||||
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
|
||||
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
|
||||
|
||||
降级策略:
|
||||
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
|
||||
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
|
||||
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
|
||||
|
||||
此函数在后台异步执行,不影响接口响应时间。
|
||||
失败时静默处理,不影响已创建的片段。
|
||||
@@ -886,12 +941,6 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
plan_svc = EditPlanService(db)
|
||||
|
||||
# 调用 MediaKit 获取推荐时间
|
||||
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
|
||||
if not recommendations:
|
||||
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
|
||||
return
|
||||
|
||||
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
|
||||
batch_size = 500
|
||||
all_clips = []
|
||||
@@ -914,15 +963,16 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
|
||||
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
|
||||
|
||||
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
|
||||
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
|
||||
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段)
|
||||
clips_by_asset: dict[str, list] = defaultdict(list)
|
||||
for clip in clips:
|
||||
aid = getattr(clip, "asset_id", "") or ""
|
||||
if aid and clip.start_time is not None:
|
||||
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
|
||||
if aid:
|
||||
clips_by_asset[aid].append(clip)
|
||||
for aid in clips_by_asset:
|
||||
clips_by_asset[aid].sort(key=lambda c: c.order)
|
||||
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
|
||||
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
|
||||
historical_segments = get_used_segments(db, unique_asset_ids)
|
||||
|
||||
# 已更新的片段ID(用于排除已移动的旧时间段)
|
||||
@@ -931,16 +981,22 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
updated_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
updated_count = 0
|
||||
|
||||
# 遍历片段,按 asset_id 匹配推荐时间
|
||||
for clip in clips:
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
if not asset_id or asset_id not in recommendations:
|
||||
# 尝试获取存储服务(用于生成视频 URL)
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception:
|
||||
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
|
||||
return
|
||||
|
||||
# 获取 MediaKit 客户端
|
||||
client = get_mediakit_client()
|
||||
|
||||
# 对每个素材,检测场景切换点并分配镜头段
|
||||
for asset_id in unique_asset_ids:
|
||||
asset_clips = clips_by_asset.get(asset_id, [])
|
||||
if not asset_clips:
|
||||
continue
|
||||
|
||||
recommended_start = recommendations[asset_id]
|
||||
clip_duration = clip.duration
|
||||
|
||||
# 从预加载字典获取素材(O(1) 查找)
|
||||
asset = assets_map.get(asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
@@ -948,89 +1004,143 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if asset_total <= 0:
|
||||
continue
|
||||
|
||||
# 推荐时间 + 片段时长不能超过素材总时长
|
||||
if recommended_start + clip_duration > asset_total:
|
||||
logger.info(
|
||||
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
clip_duration,
|
||||
asset_total,
|
||||
)
|
||||
continue
|
||||
|
||||
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
|
||||
other_segments: list[tuple[float, float]] = [
|
||||
(cs, ce)
|
||||
for cid, cs, ce in clips_by_asset.get(asset_id, [])
|
||||
if cid != clip.id and cid not in updated_clip_ids
|
||||
]
|
||||
other_segments.extend(updated_segments.get(asset_id, []))
|
||||
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
|
||||
# 本 plan 片段创建时已写入历史记录
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
|
||||
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
|
||||
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
|
||||
def _norm(segs):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
|
||||
|
||||
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
|
||||
|
||||
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
|
||||
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
|
||||
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
|
||||
logger.info(
|
||||
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
continue
|
||||
|
||||
# 逐个更新并捕获异常(单点失败不影响其他片段)
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
|
||||
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
|
||||
# 写新区间,最后统一 commit;任一步失败整体 rollback,
|
||||
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
# 获取素材视频 URL
|
||||
video_url: str | None = None
|
||||
storage_key = getattr(asset, "storage_key", None) or ""
|
||||
mime = getattr(asset, "mime_type", "") or ""
|
||||
if storage_key and mime.startswith("video/"):
|
||||
try:
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
video_url = storage.get_download_url(storage_key)
|
||||
except Exception as e:
|
||||
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
# 构建该素材的占用区间列表(排除已更新片段)
|
||||
def _get_other_segments(asset_id_inner, clip_id_inner):
|
||||
segs: list[tuple[float, float]] = []
|
||||
for c in clips_by_asset.get(asset_id_inner, []):
|
||||
cid = c.id
|
||||
if cid != clip_id_inner and cid not in updated_clip_ids:
|
||||
segs.append((c.start_time, c.start_time + c.duration))
|
||||
segs.extend(updated_segments.get(asset_id_inner, []))
|
||||
# 并入历史已用区间
|
||||
def _norm(segs_in):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
|
||||
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
|
||||
|
||||
# 优先使用 SceneChange 策略
|
||||
scene_segments: list[tuple[float, float]] = []
|
||||
if client.is_available and video_url:
|
||||
scene_changes = client.detect_scene_changes(video_url)
|
||||
if scene_changes is not None:
|
||||
scene_segments = _build_scene_segments(scene_changes, asset_total)
|
||||
logger.info(
|
||||
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
|
||||
asset_id, len(scene_segments),
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
if not scene_segments and video_url:
|
||||
fallback_recs = _get_mediakit_recommendations([asset_id], asset_repo)
|
||||
if fallback_recs and asset_id in fallback_recs:
|
||||
# analyze_videos 只返回单个推荐点,转为单镜头段
|
||||
rec_start = fallback_recs[asset_id]
|
||||
scene_segments = [(rec_start, asset_total)]
|
||||
logger.info(
|
||||
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
|
||||
asset_id, rec_start,
|
||||
)
|
||||
|
||||
if not scene_segments:
|
||||
# 所有方式都失败 → 保持现有随机 start_time
|
||||
logger.info(
|
||||
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
|
||||
asset_id,
|
||||
)
|
||||
continue
|
||||
|
||||
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
# 为每个片段分配不同的镜头段
|
||||
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
|
||||
for clip in asset_clips:
|
||||
clip_duration = clip.duration
|
||||
recommended_start: float | None = None
|
||||
|
||||
# 从镜头段池中依次尝试,选一个不冲突的
|
||||
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool):
|
||||
candidate_start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
if candidate_start is None:
|
||||
continue # 镜头段太短,跳过
|
||||
|
||||
# 检查越界
|
||||
if candidate_start + clip_duration > asset_total:
|
||||
continue
|
||||
|
||||
# 检查与已用区间冲突
|
||||
other_segs = _get_other_segments(asset_id, clip.id)
|
||||
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
|
||||
continue
|
||||
|
||||
recommended_start = candidate_start
|
||||
# 消费该镜头段(从池中移除,下一个片段用不同镜头段)
|
||||
scene_segments_pool.pop(seg_idx)
|
||||
break
|
||||
|
||||
if recommended_start is None:
|
||||
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底
|
||||
used_segs_for_calc: dict[str, list[tuple[float, float]]] = {
|
||||
asset_id: _get_other_segments(asset_id, clip.id)
|
||||
}
|
||||
fallback_start = _calc_random_start_time(
|
||||
asset_id,
|
||||
clip_duration,
|
||||
{asset_id: asset_total},
|
||||
used_segs_for_calc,
|
||||
)
|
||||
if fallback_start is None:
|
||||
continue # 完全无法分配,保持原起点
|
||||
recommended_start = fallback_start
|
||||
|
||||
# 更新片段起始时间
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
try:
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
|
||||
@@ -119,6 +119,70 @@ class MediaKitClient:
|
||||
|
||||
return None
|
||||
|
||||
def detect_scene_changes(
|
||||
self,
|
||||
video_url: str,
|
||||
max_frames: int = 20,
|
||||
poll_interval: float = 2.0,
|
||||
max_poll_attempts: int = 30,
|
||||
) -> Optional[List[float]]:
|
||||
"""检测视频场景切换点,返回时间戳列表.
|
||||
|
||||
降级策略:
|
||||
1. 先尝试 SceneChange 策略
|
||||
2. SceneChange 失败(OOM等)→ 退回 TimeInterval(5秒间隔)
|
||||
3. MediaKit 不可用 → 返回 None
|
||||
|
||||
Returns:
|
||||
场景切换点时间戳列表,如 [0.0, 3.2, 7.8, 12.5]
|
||||
失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
logger.warning("MediaKit 未配置,跳过场景检测")
|
||||
return None
|
||||
|
||||
# 策略1:尝试 SceneChange
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SceneChange",
|
||||
max_frames=max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
# 策略2:SceneChange 失败 → 退回 TimeInterval(5秒间隔)
|
||||
if frames is None:
|
||||
logger.info("SceneChange 策略失败,降级为 TimeInterval(5秒间隔)")
|
||||
# 估算帧数:假设视频最长60秒,每5秒一帧
|
||||
ti_max_frames = max(max_frames, 12)
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="TimeInterval",
|
||||
max_frames=ti_max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
if frames is None:
|
||||
return None
|
||||
|
||||
# 从帧列表中提取 timestamp,排序
|
||||
timestamps = sorted({float(f.get("timestamp", 0.0)) for f in frames if "timestamp" in f})
|
||||
|
||||
if not timestamps:
|
||||
return None
|
||||
|
||||
# 始终在列表开头加 0.0(素材起始点)
|
||||
if timestamps[0] != 0.0:
|
||||
timestamps.insert(0, 0.0)
|
||||
|
||||
logger.info(
|
||||
"场景检测完成: video_url=%s scene_changes=%s",
|
||||
video_url[:80],
|
||||
timestamps,
|
||||
)
|
||||
return timestamps
|
||||
|
||||
def _submit_extract_task(
|
||||
self,
|
||||
video_url: str,
|
||||
|
||||
@@ -418,8 +418,13 @@ class TestEditorClipsDurationAndStartTime:
|
||||
|
||||
assert mock_calc.call_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["start_time"] == 12.5
|
||||
assert clips_data[1]["start_time"] == 18.0
|
||||
# clips_data 按 order 排序,但分配顺序因 shuffle 而随机,
|
||||
# 因此只验证两个 start_time 值都存在
|
||||
start_times = {c["start_time"] for c in clips_data}
|
||||
assert start_times == {12.5, 18.0}
|
||||
# 验证 order 仍然有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_asset_durations_deduped(self, mock_storage):
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user