24e66da061
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
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 / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 1m13s
CI/CD Pipeline / Validate - Style (push) Successful in 6m6s
CI/CD Pipeline / Integration Tests (push) Successful in 6m16s
CI/CD Pipeline / Validate - Security (push) Successful in 7m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m4s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m57s
CI/CD Pipeline / Unit Tests (push) Successful in 14m10s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m16s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m39s
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
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 6m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 8m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m3s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 8m15s
AI Code Review / AI Code Review (pull_request) Failing after 8m21s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m22s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m37s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5m19s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
369 lines
15 KiB
Python
369 lines
15 KiB
Python
"""测试 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()
|