"""测试 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()