feat: SceneChange 场景检测前置到渲染前 + 场景点缓存到素材 metadata #1622

Merged
auto-approve-bot merged 2 commits from feat/scene-change-pre-render into develop 2026-09-02 08:40:37 +08:00
4 changed files with 674 additions and 69 deletions
@@ -47,7 +47,13 @@ from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
extract_scene_points_from_metadata,
pick_scene_aware_start,
pick_start_in_scene_segment,
)
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.shared.mediakit_client import get_mediakit_client
@@ -475,6 +481,12 @@ def _recommended_time_conflicts(
return False
# 向后兼容别名:镜头段构建/段内取点逻辑已下沉到 packages.domain.plan_generator_utils
# 旧测试与历史代码仍按 clips._build_scene_segments / _pick_start_in_scene_segment 导入
_build_scene_segments = build_scene_segments
_pick_start_in_scene_segment = pick_start_in_scene_segment
def _get_mediakit_recommendations(
asset_ids: list[str],
asset_repo,
@@ -673,6 +685,9 @@ def create_clips_from_assets_editor(
unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {}
asset_smart_scores: dict[str, float] = {}
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
asset_scene_points: dict[str, list[float]] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
@@ -680,6 +695,15 @@ def create_clips_from_assets_editor(
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
asset_scene_points[asset_id] = cached_points
logger.info(
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
len(asset_scene_points),
len(unique_asset_ids),
)
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
@@ -771,16 +795,30 @@ def create_clips_from_assets_editor(
candidate,
)
continue
# 随机起始时间(不调用 MediaKit,保证接口快速返回)100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 10% 则拒绝并返回 None
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
# 起始时间选取(不调用 MediaKit,保证接口快速返回)
# 1) 素材有场景切换点缓存时,优先从随机镜头段中选起点(不同片段来自不同镜头,
# 画面内容本质不同),与 used_segments 做冲突避让(含 1.5s 边缘间隙
# 2) 无缓存 / 镜头段全冲突 → _calc_random_start_time 随机起点兜底;
# 100 次避不开历史区间时走受控复用回调(复用片段累加 reused_durations
# 回调内部预判复用后占比超 10% 则拒绝并返回 None)
candidate_start = None
if candidate in asset_scene_points:
candidate_start = pick_scene_aware_start(
candidate,
candidate_duration,
asset_durations,
asset_scene_points,
used_segments,
edge_gap=SEGMENT_EDGE_GAP,
)
if candidate_start is None:
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
if candidate_start is None:
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材
logger.info(
@@ -869,45 +907,6 @@ 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],
@@ -1032,15 +1031,45 @@ def _update_mediakit_recommendations_async( # pragma: no cover
# 优先使用 SceneChange 策略
scene_segments: list[tuple[float, float]] = []
if client.is_available and video_url:
# 先查素材 metadata 中的场景点缓存:命中则直接复用,跳过 MediaKit 检测
# (缓存由本任务首次检测后写入,跨任务/跨 plan 复用)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
scene_segments = build_scene_segments(cached_points, asset_total)
logger.info(
"后台任务: 命中场景点缓存: asset_id=%s scenes=%d",
asset_id,
len(scene_segments),
)
if not scene_segments and 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)
scene_segments = build_scene_segments(scene_changes, asset_total)
logger.info(
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
asset_id,
len(scene_segments),
)
# 检测结果写入素材 metadata 缓存:首次生成用随机起点,
# 检测完成后后续生成的渲染前同步路径即可读缓存选镜头段
try:
existing_meta = dict(getattr(asset, "metadata", None) or {})
existing_meta["scene_change_points"] = scene_changes
asset.metadata = existing_meta
asset_repo.update(asset)
logger.info(
"后台任务: 场景点已写入素材缓存: asset_id=%s points=%d",
asset_id,
len(scene_changes),
)
except Exception as cache_err:
# 缓存写入失败不影响本次片段更新
logger.warning(
"后台任务: 场景点缓存写入失败: asset_id=%s error=%s",
asset_id,
cache_err,
)
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
if not scene_segments and video_url:
@@ -1071,7 +1100,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover
# 从镜头段池中依次尝试,选一个不冲突的
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)
candidate_start = pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
if candidate_start is None:
continue # 镜头段太短,跳过
@@ -30,6 +30,7 @@ from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import (
create_clips_from_configs,
distribute_assets,
extract_scene_points_from_metadata,
generate_default_clips,
map_clip_types_for_mode,
)
@@ -223,9 +224,15 @@ class PlanGeneratorService:
先用 smart_match 评分对素材排序(高分优先),再委托给
plan_generator_utils.distribute_assets 纯函数完成分配。
"""
# 用 smart_match 评分排序素材:高分(质量好/时长合适/新鲜/未使用)优先
if self._asset_repo and not random_selection:
asset_ids = self._sort_assets_by_smart_score(asset_ids)
# 预览随机模式:素材顺序已 shuffle,纯随机起点即可,不读 DB 评分/缓存
asset_scene_points: dict[str, list[float]] = {}
if not random_selection:
# 正式生成:smart_match 评分排序(高分优先)+ 场景切换点缓存
if self._asset_repo:
asset_ids = self._sort_assets_by_smart_score(asset_ids)
# 读取素材 metadata 中的场景切换点缓存(后台 SceneChange 检测写入):
# 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底
asset_scene_points = self._fetch_asset_scene_points(asset_ids)
distribute_assets(
clips,
@@ -233,8 +240,22 @@ class PlanGeneratorService:
editing_mode,
random_selection=random_selection,
asset_durations=asset_durations,
asset_scene_points=asset_scene_points,
)
def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]:
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
points_map: dict[str, list[float]] = {}
if not self._asset_repo:
return points_map
for asset_id in asset_ids:
asset = self._asset_repo.get(asset_id)
if asset:
points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if points:
points_map[asset_id] = points
return points_map
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
+200 -13
View File
@@ -27,6 +27,137 @@ DEFAULT_INTRO_DURATION = 3.0
DEFAULT_OUTRO_DURATION = 3.0
# ── SceneChange 镜头段工具 ────────────────────────────────────────────────────
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), ...],仅保留长度 >= 0.5s 的段
"""
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 _segments_overlap(
start: float,
duration: float,
used: list[tuple[float, float]],
edge_gap: float = 0.0,
) -> bool:
"""候选区间 [start, start+duration] 是否与已用区间冲突(含边缘间隙扩边)。"""
end = start + duration
for used_start, used_end in used:
if start < used_end + edge_gap and end > used_start - edge_gap:
return True
return False
def pick_scene_aware_start(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float],
asset_scene_points: dict[str, list[float]] | None,
used_segments: dict[str, list[tuple[float, float]]],
*,
edge_gap: float = 0.0,
) -> float | None:
"""基于缓存的场景切换点,从随机镜头段中选取不冲突的起始时间.
流程:
1. 读取 asset_scene_points 中该素材的场景切换点缓存 → 构建镜头段
2. random.shuffle 镜头段(保证同一素材多次生成选不同镜头,而非固定第N段)
3. 依次尝试:段内随机取点 → 越界检查 → 与 used_segments 冲突检查
4. 全部冲突/无缓存 → 返回 None,由调用方回退 _calc_random_start_time
Args:
asset_id: 素材 ID
clip_duration: 片段时长(秒)
asset_durations: 素材 ID -> 总时长
asset_scene_points: 素材 ID -> 场景切换点列表(metadata 缓存)
used_segments: 素材 ID -> 已用区间列表(冲突避让)
edge_gap: 冲突判定的边缘间隙(秒),已用区间按 [s-gap, e+gap] 扩边
"""
asset_total = (asset_durations or {}).get(asset_id)
if not asset_total or asset_total <= 0:
return None
scene_points = (asset_scene_points or {}).get(asset_id)
if not scene_points:
return None
used = used_segments.get(asset_id, []) if used_segments else []
scene_segments = build_scene_segments(scene_points, asset_total)
if not scene_segments:
return None
random.shuffle(scene_segments)
for seg_start, seg_end in scene_segments:
candidate = pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
if candidate is None:
continue
# 越界检查(防御:场景点末尾段理论上不越界,metadata 脏数据兜底)
if candidate + clip_duration > asset_total:
continue
# 与已用区间冲突检查
if _segments_overlap(candidate, clip_duration, used, edge_gap):
continue
return candidate
return None
def extract_scene_points_from_metadata(metadata: object) -> list[float] | None:
"""从素材 metadata 中提取并校验场景切换点缓存.
合法缓存:list 类型、至少 2 个数值点、单调非负;否则返回 None(按未缓存处理)。
"""
if not isinstance(metadata, dict):
return None
points = metadata.get("scene_change_points")
if not isinstance(points, list) or len(points) < 2:
return None
try:
cleaned = [float(p) for p in points]
except (TypeError, ValueError):
return None
if any(p < 0 for p in cleaned):
return None
cleaned = sorted(cleaned)
if cleaned[0] != 0.0:
cleaned.insert(0, 0.0)
return cleaned
# ── 素材分配 ────────────────────────────────────────────────────────────────
@@ -37,6 +168,7 @@ def distribute_assets(
*,
random_selection: bool = False,
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改).
@@ -46,12 +178,16 @@ def distribute_assets(
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
start_time 选取:素材 metadata 中有场景切换点缓存时,优先从随机镜头段
取起点(不同片段来自不同镜头);无缓存或镜头段都冲突时回退随机起点。
Args:
clips: 剪辑片段列表(就地修改 asset_id)
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式字符串
random_selection: 是否随机选择素材(用于预览生成)
asset_durations: 素材 ID -> 时长(秒)映射,用于设置随机 start_time
asset_durations: 素材 ID -> 时长(秒)映射,用于设置 start_time
asset_scene_points: 素材 ID -> 场景切换点列表(metadata 缓存)
"""
if not asset_ids or not clips:
return
@@ -62,22 +198,56 @@ def distribute_assets(
random.shuffle(asset_ids)
if editing_mode == EditingMode.ONE_TAKE.value:
_distribute_one_take(clips, asset_ids, asset_durations)
_distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points)
elif editing_mode == EditingMode.PIP.value:
_distribute_pip(clips, asset_ids, asset_durations)
_distribute_pip(clips, asset_ids, asset_durations, asset_scene_points)
elif editing_mode == EditingMode.VOICE_OVER.value:
_distribute_voice_over(clips, asset_ids, asset_durations)
_distribute_voice_over(clips, asset_ids, asset_durations, asset_scene_points)
elif editing_mode == EditingMode.VOICE_PIP.value:
_distribute_voice_pip(clips, asset_ids, asset_durations)
_distribute_voice_pip(clips, asset_ids, asset_durations, asset_scene_points)
else:
# 未知模式,退化为 one_take
_distribute_one_take(clips, asset_ids, asset_durations)
_distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points)
def _resolve_start_time(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float] | None,
used_segments: dict[str, list[tuple[float, float]]],
asset_scene_points: dict[str, list[float]] | None = None,
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
) -> float | None:
"""选取片段起点:场景缓存优先(随机镜头段),无缓存/全冲突回退随机起点.
场景路径与随机路径共享 used_segments 冲突避让;场景路径返回 None 时
(无缓存、镜头段全冲突)回退 _calc_random_start_time,其受控复用逻辑
on_exhausted)不受影响。
"""
if asset_scene_points and asset_scene_points.get(asset_id):
scene_start = pick_scene_aware_start(
asset_id,
clip_duration,
asset_durations or {},
asset_scene_points,
used_segments,
)
if scene_start is not None:
return scene_start
return _calc_random_start_time(
asset_id,
clip_duration,
asset_durations,
used_segments,
on_exhausted=on_exhausted,
)
def _distribute_one_take(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips."""
used_segments: dict[str, list[tuple[float, float]]] = {}
@@ -85,7 +255,9 @@ def _distribute_one_take(
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
asset_id = asset_ids[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -98,6 +270,7 @@ def _distribute_pip(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips."""
used_segments: dict[str, list[tuple[float, float]]] = {}
@@ -105,7 +278,9 @@ def _distribute_pip(
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
asset_id = asset_ids[0]
start_time = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, main_clips[0].duration, asset_durations, used_segments, asset_scene_points
)
main_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -119,7 +294,9 @@ def _distribute_pip(
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -132,6 +309,7 @@ def _distribute_voice_over(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)."""
used_segments: dict[str, list[tuple[float, float]]] = {}
@@ -139,7 +317,9 @@ def _distribute_voice_over(
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
asset_id = asset_ids[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -152,6 +332,7 @@ def _distribute_voice_pip(
clips: List[EditPlanClip],
asset_ids: List[str],
asset_durations: dict[str, float] | None = None,
asset_scene_points: dict[str, list[float]] | None = None,
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll."""
used_segments: dict[str, list[tuple[float, float]]] = {}
@@ -164,7 +345,9 @@ def _distribute_voice_pip(
# 第1个 → background
if idx < len(asset_ids) and bg_clips:
asset_id = asset_ids[idx]
start_time = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, bg_clips[0].duration, asset_durations, used_segments, asset_scene_points
)
bg_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -176,7 +359,9 @@ def _distribute_voice_pip(
# 第2个 → corner_voice
if idx < len(asset_ids) and voice_clips:
asset_id = asset_ids[idx]
start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, voice_clips[0].duration, asset_durations, used_segments, asset_scene_points
)
voice_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
@@ -190,7 +375,9 @@ def _distribute_voice_pip(
for i, clip in enumerate(broll_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _resolve_start_time(
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
+368
View File
@@ -0,0 +1,368 @@
"""测试 SceneChange 场景检测前置到渲染前 + 场景点缓存读写。
验证:
- 场景点缓存读取(extract_scene_points_from_metadata):合法/非法/脏数据
- pick_scene_aware_start:随机镜头段选取、冲突避让、shuffle 随机化、无缓存回退 None
- from-assets 路径:metadata 有 scene_change_points 时,start_time 落在镜头段内
- 一键生成路径:distribute_assets 传入 asset_scene_points 时使用镜头段
- 后台任务:检测结果写入素材 metadata(缓存)
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
distribute_assets,
extract_scene_points_from_metadata,
pick_scene_aware_start,
)
# ── metadata 缓存解析 ─────────────────────────────────────────────────────────
class TestExtractScenePoints:
def test_valid_points(self):
md = {"scene_change_points": [0.0, 3.2, 7.8, 12.5]}
assert extract_scene_points_from_metadata(md) == [0.0, 3.2, 7.8, 12.5]
def test_missing_returns_none(self):
assert extract_scene_points_from_metadata({}) is None
assert extract_scene_points_from_metadata(None) is None
assert extract_scene_points_from_metadata("not-a-dict") is None
def test_empty_list_returns_none(self):
assert extract_scene_points_from_metadata({"scene_change_points": []}) is None
assert extract_scene_points_from_metadata({"scene_change_points": [0.0]}) is None
def test_dirty_data_returns_none(self):
assert extract_scene_points_from_metadata({"scene_change_points": ["a", 1.0]}) is None
def test_auto_prepends_zero(self):
result = extract_scene_points_from_metadata({"scene_change_points": [3.2, 7.8]})
assert result == [0.0, 3.2, 7.8]
def test_sorts_unsorted(self):
result = extract_scene_points_from_metadata({"scene_change_points": [0.0, 12.5, 3.2, 7.8]})
assert result == [0.0, 3.2, 7.8, 12.5]
def test_negative_rejected(self):
assert extract_scene_points_from_metadata({"scene_change_points": [-1.0, 3.2]}) is None
# ── 镜头段选取 ────────────────────────────────────────────────────────────────
class TestPickSceneAwareStart:
def test_start_within_some_scene_segment(self):
"""有缓存时,起点落在某个镜头段内部。"""
points = [0.0, 5.0, 10.0, 15.0]
durations = {"a1": 18.0}
scene_points = {"a1": points}
used: dict = {}
start = pick_scene_aware_start("a1", 3.0, durations, scene_points, used)
assert start is not None
segments = build_scene_segments(points, 18.0)
assert any(seg_start <= start and start + 3.0 <= seg_end for seg_start, seg_end in segments)
def test_no_cache_returns_none(self):
"""无缓存返回 None(调用方回退随机起点)。"""
result = pick_scene_aware_start("a1", 3.0, {"a1": 18.0}, {}, {})
assert result is None
def test_bounds_respected(self):
"""起点 + 片段时长不超过素材总时长。"""
points = [0.0, 5.0, 10.0, 15.0]
for _ in range(30):
start = pick_scene_aware_start("a1", 4.0, {"a1": 18.0}, {"a1": points}, {})
assert start is not None
assert start + 4.0 <= 18.0 + 1e-6
def test_conflict_avoidance(self):
"""所有镜头段都被占满时返回 None(回退随机路径)。"""
# 3 段各 6s,片段 5s;把所有段占满([0,5.5] [5.5,11] 覆盖段1/2,段3太短放不下5s)
points = [0.0, 6.0, 12.0]
used = {"a1": [(0.0, 5.6), (6.0, 11.6)]}
# 段3 [12, 18] 可用 → 应返回其中起点
start = pick_scene_aware_start("a1", 5.0, {"a1": 18.0}, {"a1": points}, used)
assert start is not None
assert start >= 12.0
def test_all_segments_conflict_returns_none(self):
"""全部镜头段都冲突时返回 None。"""
points = [0.0, 6.0, 12.0]
# 占满整个素材
used = {"a1": [(0.0, 18.0)]}
start = pick_scene_aware_start("a1", 5.0, {"a1": 18.0}, {"a1": points}, used)
assert start is None
def test_shuffle_produces_varied_segments(self):
"""镜头段顺序被 shuffle:30 次选取,起点分布应覆盖多个镜头段。"""
points = [0.0, 5.0, 10.0, 15.0]
observed: set[int] = set()
for _ in range(40):
start = pick_scene_aware_start("a1", 2.0, {"a1": 18.0}, {"a1": points}, {})
assert start is not None
# 记录起点落在哪个段(段宽 5s
observed.add(int(start // 5.0))
assert len(observed) >= 3, f"镜头段 shuffle 后应覆盖多个段,实际 {observed}"
# ── 一键生成路径:distribute_assets 接入场景缓存 ──────────────────────────────
class TestDistributeWithScenePoints:
def _make_clips(self, n):
from packages.domain.edit_plan_clip import EditPlanClip
return [EditPlanClip(id=f"c{i}", plan_id="p1", clip_type="main", duration=4.0, order=i) for i in range(n)]
def test_one_take_uses_scene_segments(self):
"""ONE_TAKE 模式下,有场景缓存的素材起点落在镜头段内。"""
from packages.domain.editing_mode import EditingMode
clips = self._make_clips(2)
points = [0.0, 6.0, 12.0, 18.0]
distribute_assets(
clips,
["a1"],
EditingMode.ONE_TAKE.value,
asset_durations={"a1": 24.0},
asset_scene_points={"a1": points},
)
segments = build_scene_segments(points, 24.0)
for clip in clips:
assert clip.start_time is not None
assert any(
s <= clip.start_time and clip.start_time + 4.0 <= e for s, e in segments
), f"起点 {clip.start_time} 不在任何镜头段内"
def test_no_scene_points_falls_back_random(self):
"""无场景缓存时正常分配(回退随机起点),不报错。"""
from packages.domain.editing_mode import EditingMode
clips = self._make_clips(1)
distribute_assets(
clips,
["a1"],
EditingMode.ONE_TAKE.value,
asset_durations={"a1": 24.0},
asset_scene_points={},
)
for clip in clips:
assert clip.asset_id == "a1"
assert clip.start_time is not None
assert 0.0 <= clip.start_time <= 20.0
def test_random_preview_ignores_scene_points(self):
"""random_selection 预览模式行为不变(不崩溃、正常分配)。"""
from packages.domain.editing_mode import EditingMode
clips = self._make_clips(2)
distribute_assets(
clips,
["a1", "a2"],
EditingMode.ONE_TAKE.value,
random_selection=True,
asset_durations={"a1": 24.0, "a2": 24.0},
asset_scene_points={"a1": [0.0, 6.0]},
)
assert all(c.asset_id for c in clips)
# ── from-assets 路径:渲染前读缓存选镜头段 ────────────────────────────────────
def _make_auth_user():
auth = MagicMock()
auth.user.id = "user-001"
auth.user.email = "test@example.com"
auth.user.display_name = "测试用户"
auth.user_id = "user-001"
return auth
def _make_asset_with_scenes(aid, duration, scene_points=None):
asset = MagicMock()
asset.id = aid
asset.duration = duration
asset.quality_score = None
asset.created_at = None
asset.metadata = {"scene_change_points": scene_points} if scene_points is not None else {}
return asset
class TestFromAssetsSceneCache:
def test_cached_scene_points_used_for_start_time(self):
"""素材 metadata 有场景点缓存时,片段起点落在镜头段内。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 镜头段宽度 10s,片段最长 ~5.3s(含转场补偿),每段都能容纳
scene_points = [0.0, 10.0, 20.0, 30.0]
asset_duration = 40.0
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(
side_effect=lambda aid: _make_asset_with_scenes(aid, asset_duration, scene_points)
)
mock_plan_svc = MagicMock()
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=3)
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0)]
with (
patch(
"app.api.routes.templates_editor.clips._get_template_segments",
return_value=segments,
),
patch("app.api.routes.templates_editor.clips.get_used_segments", return_value={}),
patch("app.api.routes.templates_editor.clips.record_used_segments", return_value=None),
):
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-scene-1",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = mock_plan_svc.replace_all_clips_transactional.call_args.args[1]
scene_segments = build_scene_segments(scene_points, asset_duration)
for clip in clips_data:
start = clip["start_time"]
dur = clip["duration"]
in_segment = any(s <= start and start + dur <= e + 0.1 for s, e in scene_segments)
assert in_segment, f"起点 {start:.2f} 时长 {dur:.2f} 不在任何镜头段内"
def test_no_cache_falls_back_random_no_error(self):
"""素材无场景缓存时正常走随机起点,流程不报错。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_asset_with_scenes(aid, 30.0, None))
mock_plan_svc = MagicMock()
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
with (
patch(
"app.api.routes.templates_editor.clips._get_template_segments",
return_value=segments,
),
patch("app.api.routes.templates_editor.clips.get_used_segments", return_value={}),
patch("app.api.routes.templates_editor.clips.record_used_segments", return_value=None),
):
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-scene-2",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = mock_plan_svc.replace_all_clips_transactional.call_args.args[1]
assert len(clips_data) == 2
assert all(c["start_time"] is not None for c in clips_data)
# ── 后台任务:检测结果写缓存 ──────────────────────────────────────────────────
class TestAsyncCacheWrite:
def test_scene_points_persisted_to_metadata(self):
"""detect_scene_changes 返回结果后写入素材 metadata 并调用 repo.update。"""
from app.api.routes.templates_editor import clips as clips_module
detected_points = [0.0, 4.5, 9.0, 14.2]
mock_asset = MagicMock()
mock_asset.id = "a1"
mock_asset.duration = 20.0
mock_asset.storage_key = "v.mp4"
mock_asset.mime_type = "video/mp4"
mock_asset.metadata = {}
mock_asset_repo = MagicMock()
mock_asset_repo.find_by_ids = MagicMock(return_value=[mock_asset])
mock_asset_repo.update = MagicMock(side_effect=lambda a: a)
mock_clip = MagicMock()
mock_clip.id = "clip-1"
mock_clip.asset_id = "a1"
mock_clip.order = 0
mock_clip.start_time = 2.0
mock_clip.duration = 4.0
mock_plan_svc = MagicMock()
mock_plan_svc.list_clips = MagicMock(return_value=[mock_clip])
mock_plan_svc.update_clip = MagicMock()
plan_svc_factory = MagicMock(return_value=mock_plan_svc)
mock_client = MagicMock()
mock_client.is_available = True
mock_client.detect_scene_changes = MagicMock(return_value=detected_points)
mock_storage = MagicMock()
mock_storage.get_download_url = MagicMock(return_value="https://example.com/v.mp4")
mock_session = MagicMock()
with (
patch(
"packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
return_value=mock_asset_repo,
),
patch(
"app.api.routes.templates_editor.clips.EditPlanService",
plan_svc_factory,
),
patch(
"packages.adapters.sqlalchemy_impl.session.SessionLocal",
MagicMock(return_value=mock_session),
),
patch(
"app.api.routes.templates_editor.clips.get_storage_service",
return_value=mock_storage,
),
patch(
"app.api.routes.templates_editor.clips.get_mediakit_client",
return_value=mock_client,
),
patch(
"app.api.routes.templates_editor.clips.get_used_segments",
return_value={},
),
patch(
"app.api.routes.templates_editor.clips.record_used_segments",
return_value=None,
),
patch(
"app.api.routes.templates_editor.clips.remove_used_segment",
return_value=False,
),
):
clips_module._update_mediakit_recommendations_async("plan-1", ["a1"])
# 验证素材 metadata 被写入场景点并持久化
assert mock_asset.metadata.get("scene_change_points") == detected_points
mock_asset_repo.update.assert_called()