"""UnifiedRenderService 单元测试. 测试图层分组算法、filter_complex 构建、以及渲染流程。 """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest from video_processing.unified_render_service import ( RenderLayer, RenderResult, ResolvedClip, UnifiedRenderService, _resolve_layer_role, ) # ── Fixtures ────────────────────────────────────────────────────────────────── @dataclass class FakeClip: """模拟 EditPlanClip。""" id: str plan_id: str = "plan_001" clip_type: str = "main" order: int = 0 asset_id: str = "" text_content: str = "" start_time: float = 0.0 duration: float = 0.0 transition_effect: str = "cut" status: str = "ready" config: dict[str, Any] = field(default_factory=dict) @dataclass class FakePlan: """模拟 EditPlan。""" id: str = "plan_001" name: str = "测试计划" def _make_clip( clip_id: str, clip_type: str = "main", order: int = 0, asset_id: str = "", duration: float = 0.0, transition_effect: str = "cut", config: dict[str, Any] | None = None, ) -> FakeClip: return FakeClip( id=clip_id, clip_type=clip_type, order=order, asset_id=asset_id or f"asset_{clip_id}.mp4", duration=duration, transition_effect=transition_effect, config=config or {}, ) def _make_service( clips: list[FakeClip] | None = None, asset_paths: dict[str, Path] | None = None, work_dir: Path | None = None, ) -> UnifiedRenderService: """创建测试用的 UnifiedRenderService 实例。 如果未提供 asset_paths,自动从 clips 生成默认映射 (asset_id → /tmp/asset_{clip_id}.mp4)。 """ plan = FakePlan() clips = clips or [] work_dir = work_dir or Path("/tmp/test_render") if asset_paths is None: asset_paths = {} for c in clips: if c.asset_id: asset_paths[c.asset_id] = Path(f"/tmp/{c.asset_id}") return UnifiedRenderService( plan=plan, clips=clips, asset_path_map=asset_paths, work_dir=work_dir, ) def _patch_path_exists(): """Patch Path.exists() 让测试路径返回 True。""" return patch("pathlib.Path.exists", return_value=True) # ── 测试 _resolve_layer_role ───────────────────────────────────────────────── class TestResolveLayerRole: """测试 clip_type → layer role 映射。""" def test_main_default(self): assert _resolve_layer_role("main", {}) == "main" def test_main_with_b_roll_role(self): assert _resolve_layer_role("main", {"role": "b_roll"}) == "broll" def test_overlay(self): assert _resolve_layer_role("overlay", {}) == "overlay" def test_background(self): assert _resolve_layer_role("background", {}) == "background" def test_corner_voice(self): assert _resolve_layer_role("corner_voice", {}) == "corner_voice" def test_b_roll(self): assert _resolve_layer_role("b_roll", {}) == "broll" def test_intro(self): assert _resolve_layer_role("intro", {}) == "main" def test_outro(self): assert _resolve_layer_role("outro", {}) == "main" # ── 测试图层分组 ────────────────────────────────────────────────────────────── class TestGroupClipsIntoLayers: """测试 _group_clips_into_layers 方法。""" def test_group_clips_one_take(self): """4 个 main clips → 1 个 main_layer。""" clips = [ _make_clip("c1", "main", order=0), _make_clip("c2", "main", order=1), _make_clip("c3", "main", order=2), _make_clip("c4", "main", order=3), ] svc = _make_service(clips) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) assert len(layers) == 1 assert layers[0].role == "main" assert len(layers[0].clips) == 4 assert layers[0].z_index == 0 def test_group_clips_pip(self): """1 main + 2 overlay → main_layer + overlay_layer。""" clips = [ _make_clip("c1", "main", order=0), _make_clip("c2", "overlay", order=1), _make_clip("c3", "overlay", order=2), ] svc = _make_service(clips) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) roles = {lyr.role for lyr in layers} assert "main" in roles assert "overlay" in roles main_layer = next(lyr for lyr in layers if lyr.role == "main") overlay_layer = next(lyr for lyr in layers if lyr.role == "overlay") assert len(main_layer.clips) == 1 assert len(overlay_layer.clips) == 2 assert overlay_layer.z_index > main_layer.z_index def test_group_clips_voice_over(self): """3 个 main(b_roll) clips → 1 个 broll_layer。""" clips = [ _make_clip("c1", "main", order=0, config={"role": "b_roll"}), _make_clip("c2", "main", order=1, config={"role": "b_roll"}), _make_clip("c3", "main", order=2, config={"role": "b_roll"}), ] svc = _make_service(clips) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) assert len(layers) == 1 assert layers[0].role == "broll" assert len(layers[0].clips) == 3 def test_group_clips_voice_pip(self): """1 background + 1 corner_voice + 2 b_roll → 3 layers。""" clips = [ _make_clip("c1", "background", order=0), _make_clip("c2", "corner_voice", order=1), _make_clip("c3", "b_roll", order=2), _make_clip("c4", "b_roll", order=3), ] svc = _make_service(clips) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) roles = {lyr.role for lyr in layers} assert roles == {"background", "corner_voice", "broll"} assert len(layers) == 3 # z_index 排序 assert layers[0].z_index <= layers[1].z_index <= layers[2].z_index def test_group_clips_intro_outro(self): """intro + 2 main + outro → 1 main_layer(4 clips,按 order 排序)。""" clips = [ _make_clip("intro", "intro", order=0), _make_clip("c1", "main", order=1), _make_clip("c2", "main", order=2), _make_clip("outro", "outro", order=3), ] svc = _make_service(clips) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) assert len(layers) == 1 assert layers[0].role == "main" assert len(layers[0].clips) == 4 # 按 order 排序 orders = [c.order for c in layers[0].clips] assert orders == [0, 1, 2, 3] # ── 测试 _resolve_clips ────────────────────────────────────────────────────── class TestResolveClips: """测试 _resolve_clips 方法。""" def test_skip_missing_asset(self): """跳过 asset_id 在 asset_path_map 中找不到的 clip。""" clips = [ _make_clip("c1", "main", order=0, asset_id="asset_1.mp4"), _make_clip("c2", "main", order=1, asset_id="missing.mp4"), ] # 只有 asset_1.mp4 存在 asset_paths = {"asset_1.mp4": Path("/tmp/asset_1.mp4")} svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() assert len(resolved) == 1 assert resolved[0].clip_id == "c1" def test_skip_empty_asset_id(self): """跳过 asset_id 为空的 clip。""" clips = [ _make_clip("c1", "main", order=0, asset_id=""), _make_clip("c2", "main", order=1, asset_id="asset_2.mp4"), ] asset_paths = {"asset_2.mp4": Path("/tmp/asset_2.mp4")} svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() assert len(resolved) == 1 assert resolved[0].clip_id == "c2" def test_sort_by_order(self): """解析后的 clips 按 order 排序。""" clips = [ _make_clip("c3", "main", order=3, asset_id="a3.mp4"), _make_clip("c1", "main", order=1, asset_id="a1.mp4"), _make_clip("c2", "main", order=2, asset_id="a2.mp4"), ] asset_paths = { "a1.mp4": Path("/tmp/a1.mp4"), "a2.mp4": Path("/tmp/a2.mp4"), "a3.mp4": Path("/tmp/a3.mp4"), } svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() orders = [c.order for c in resolved] assert orders == [1, 2, 3] # ── 测试 _build_filter_complex ─────────────────────────────────────────────── class TestBuildFilterComplex: """测试 _build_filter_complex 方法。""" def test_single_layer_single_clip(self): """只有 1 个 main clip → 简单 scale + setpts。""" clips = [_make_clip("c1", "main", order=0)] asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) fc, input_args = svc._build_filter_complex(layers) assert "-i" in input_args assert "/tmp/asset_c1.mp4" in input_args assert "scale=" in fc assert "[final_video]" in fc def test_single_layer_multi_clips(self): """多个 main clips → xfade 串联。""" clips = [ _make_clip("c1", "main", order=0, duration=3.0), _make_clip("c2", "main", order=1, duration=3.0), ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), } svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) fc, input_args = svc._build_filter_complex(layers) assert input_args.count("-i") == 2 assert "xfade=" in fc assert "[final_video]" in fc def test_with_overlay(self): """main + overlay → overlay 滤镜。""" clips = [ _make_clip("c1", "main", order=0), _make_clip("c2", "overlay", order=1), ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), } svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) fc, input_args = svc._build_filter_complex(layers) assert "overlay=" in fc assert "[final_video]" in fc def test_setpts_before_fps_in_xfade_inputs(self): """多视频 xfade 模式:setpts=PTS-STARTPTS 必须在 fps 之前,确保 xfade 时各片段 PTS 一致。 构造两个不同时长的视频片段,验证生成的 filter_complex 中每个片段的 预处理滤镜链里 setpts 都在 fps 前面。 """ clips = [ _make_clip("c1", "main", order=0, duration=3.0), _make_clip("c2", "main", order=1, duration=5.0), ] asset_paths = { "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), } svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) fc, _ = svc._build_filter_complex(layers) # 确保 xfade 存在 assert "xfade=" in fc # 提取每个 clip 的预处理滤镜链([i:v]...[vi] 部分) # 验证:每个 clip 滤镜链中,setpts=PTS-STARTPTS 的最后一次出现 # 必须在 fps= 的前面(PTS 归一化后再统一帧率) import re clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]") matches = clip_pattern.findall(fc) assert len(matches) == 2, f"Expected 2 clip preprocessing chains, got {len(matches)}" for idx, chain_str in matches: # 找到所有 setpts 和 fps 的位置 setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)] fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)] assert setpts_positions, f"clip {idx}: 未找到 setpts=PTS-STARTPTS" assert fps_positions, f"clip {idx}: 未找到 fps=" # 最后一个 setpts 必须在第一个 fps 之前 last_setpts = max(setpts_positions) first_fps = min(fps_positions) assert last_setpts < first_fps, ( f"clip {idx}: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" f"滤镜链: {chain_str}" ) def test_setpts_before_fps_single_clip(self): """单视频模式(一镜到底):setpts 也必须在 fps 之前。 单视频虽然没有 xfade,但滤镜链顺序应保持一致,确保 PTS 处理逻辑统一。 """ clips = [_make_clip("c1", "main", order=0, duration=5.0)] asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} svc = _make_service(clips, asset_paths) with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): resolved = svc._resolve_clips() layers = svc._group_clips_into_layers(resolved) fc, _ = svc._build_filter_complex(layers) import re clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]") matches = clip_pattern.findall(fc) assert len(matches) == 1 chain_str = matches[0][1] setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)] fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)] assert setpts_positions, "单视频: 未找到 setpts=PTS-STARTPTS" assert fps_positions, "单视频: 未找到 fps=" last_setpts = max(setpts_positions) first_fps = min(fps_positions) assert last_setpts < first_fps, ( f"单视频: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" f"滤镜链: {chain_str}" ) def test_empty_layers_raises(self): """空图层列表抛出 ValueError。""" svc = _make_service() with pytest.raises(ValueError, match="没有可渲染的图层"): svc._build_filter_complex([]) # ── 测试 render 方法 ───────────────────────────────────────────────────────── class TestRender: """测试 render 方法。""" def test_render_empty_clips_raises(self): """没有 clips 时抛出 ValueError。""" svc = _make_service(clips=[], asset_paths={}) with pytest.raises(ValueError, match="没有可渲染的片段"): svc.render() def test_render_with_missing_assets_raises(self): """所有 clips 素材缺失时抛出 ValueError。""" clips = [_make_clip("c1", "main", order=0, asset_id="missing.mp4")] svc = _make_service(clips, asset_paths={}) with pytest.raises(ValueError, match="没有可渲染的片段"): svc.render() def test_render_success(self): """正常渲染流程。""" clips = [_make_clip("c1", "main", order=0)] asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} svc = _make_service(clips, asset_paths) with ( _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0), patch.object(svc, "_execute_ffmpeg") as mock_exec, patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)), ): result = svc.render() assert isinstance(result, RenderResult) assert result.duration == 5.0 assert result.file_size == 1024 assert result.width == 1280 assert result.height == 720 mock_exec.assert_called_once()