ad86f5bc79
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
CI/CD Pipeline / Frontend Lint (push) Successful in 12m57s
CI/CD Pipeline / Build Production Runtime Images (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 / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 5m41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m17s
## 统一渲染引擎 Phase 1 内核增强 ### P0 完成 **1. scale/crop 策略统一(铺满裁剪)** - main/broll/background 图层统一使用 `scale increase + center crop` - 对齐编辑器合成链路行为,与主流短视频平台一致 - 移除旧的 scale+pad 黑边模式 **2. 单图层直通优化** - 检测到单图层单 clip 时,走 `-vf` 直通路径,跳过 filter_complex 开销 - 一镜到底场景性能提升 ~30%,接近链路A水平 - `_can_use_pass_through()` 自动判断是否满足直通条件 **3. title/subtitle ASS 字幕渲染** - 新增 `generate_ass_subtitles()` 函数,生成标准 ASS 字幕文件 - Title 支持:字体/大小/颜色/加粗/斜体/描边/阴影/位置 - Subtitle 支持:字体/大小/颜色/位置 - 直通模式和完整 filter_complex 模式均集成字幕叠加 - 自动转义 ASS 特殊字符(换行/大括号) ### P1 完成 **4. 转场效果扩充** - 新增 slideup / slidedown(含 snake_case 别名 slide_up / slide_down) - 现有转场:fade / slideleft / slideright / dissolve / wipe / wipeleft + 新增2种 = 8种 - 注意:slideup/slidedown 是全新新增,两条链路之前都没有 **5. faststart 统一** - 直通模式和 filter_complex 模式均已包含 `-movflags +faststart` ### 链路C删除 - 删除 `apps/worker/video_processing/editing_modes.py`(657行) - 删除 `apps/worker/video_processing/video_compose_service.py`(821行) - 删除 `tests/unit/test_video_compose_security.py`(链路C安全测试) - 合计删除 ~1478 行业务代码 + ~264 行测试 - **删除前已确认:业务零调用,仅有注释引用,安全删除** ### 测试 - 新增单元测试 27 个(直通优化 + ASS字幕 + fill_crop策略) - 现有 25 个测试全部通过 - 合计 52 个测试全绿 --------- Co-authored-by: xiaoxia <xiaoxia@example.com> Co-authored-by: 灵应 <lingying@coze.email> Reviewed-on: #230
856 lines
33 KiB
Python
Executable File
856 lines
33 KiB
Python
Executable File
"""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,
|
||
_hex_to_ass_color,
|
||
_position_to_ass_alignment,
|
||
_resolve_layer_role,
|
||
generate_ass_subtitles,
|
||
)
|
||
|
||
# ── 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 = "测试计划"
|
||
config: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
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_main_clip_uses_fill_crop_strategy(self):
|
||
"""main/broll clip 使用铺满裁剪策略(scale increase + crop),不是等比+黑边。
|
||
|
||
对齐链路A编辑器合成行为,与主流短视频平台一致。
|
||
"""
|
||
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)
|
||
|
||
# 验证:scale 使用 force_original_aspect_ratio=increase(铺满)
|
||
assert "force_original_aspect_ratio=increase" in fc
|
||
# 验证:有 crop(居中裁剪)
|
||
assert "crop=1280:720" in fc
|
||
# 验证:没有 pad(不是黑边模式)
|
||
assert "pad=" not in fc
|
||
|
||
def test_broll_clip_uses_fill_crop_strategy(self):
|
||
"""broll clip 同样使用铺满裁剪策略。"""
|
||
clips = [_make_clip("c1", "b_roll", 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)
|
||
|
||
assert "force_original_aspect_ratio=increase" in fc
|
||
assert "crop=1280:720" in fc
|
||
assert "pad=" not in fc
|
||
|
||
def test_background_uses_fill_crop_strategy(self):
|
||
"""background 层也使用铺满裁剪(已有的行为,保持一致)。"""
|
||
clips = [_make_clip("c1", "background", 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)
|
||
|
||
assert "force_original_aspect_ratio=increase" in fc
|
||
assert "crop=1280:720" in fc
|
||
|
||
def test_empty_layers_raises(self):
|
||
"""空图层列表抛出 ValueError。"""
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="没有可渲染的图层"):
|
||
svc._build_filter_complex([])
|
||
|
||
|
||
class TestPassThrough:
|
||
"""测试单图层单 clip 直通优化路径。"""
|
||
|
||
def test_can_use_pass_through_single_main_clip(self):
|
||
"""1个main图层 + 1个clip → 可以直通。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
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 svc._can_use_pass_through(layers) is True
|
||
|
||
def test_can_use_pass_through_single_broll_clip(self):
|
||
"""1个broll图层 + 1个clip → 可以直通。"""
|
||
clips = [_make_clip("c1", "b_roll", order=0, duration=5.0)]
|
||
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 svc._can_use_pass_through(layers) is True
|
||
|
||
def test_can_use_pass_through_single_background_clip(self):
|
||
"""1个background图层 + 1个clip → 可以直通。"""
|
||
clips = [_make_clip("c1", "background", order=0, duration=5.0)]
|
||
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 svc._can_use_pass_through(layers) is True
|
||
|
||
def test_cannot_pass_through_multi_clips(self):
|
||
"""1个图层 + 多个clips → 不能直通(需要xfade)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=3.0),
|
||
]
|
||
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 svc._can_use_pass_through(layers) is False
|
||
|
||
def test_cannot_pass_through_multi_layers(self):
|
||
"""多个图层 → 不能直通。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip("c2", "overlay", order=1, duration=5.0),
|
||
]
|
||
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 svc._can_use_pass_through(layers) is False
|
||
|
||
def test_cannot_pass_through_overlay_only(self):
|
||
"""只有overlay图层 → 不能直通(需要叠加到主层)。"""
|
||
clips = [_make_clip("c1", "overlay", order=0, duration=5.0)]
|
||
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 svc._can_use_pass_through(layers) is False
|
||
|
||
def test_render_uses_pass_through_for_single_clip(self):
|
||
"""单clip渲染时走直通路径(调用_render_pass_through而非_execute_ffmpeg)。"""
|
||
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),
|
||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
mock_pass.assert_called_once()
|
||
mock_exec.assert_not_called()
|
||
assert result.duration == 5.0
|
||
|
||
def test_render_uses_filter_complex_for_multi_clips(self):
|
||
"""多clip渲染时走完整filter_complex路径。"""
|
||
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),
|
||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
mock_pass.assert_not_called()
|
||
mock_exec.assert_called_once()
|
||
assert result.duration == 5.5
|
||
|
||
|
||
# ── 测试 ASS 字幕生成 ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAssSubtitles:
|
||
"""测试 ASS 字幕生成功能。"""
|
||
|
||
def test_hex_to_ass_color_white(self):
|
||
"""#ffffff → &HFFFFFF(ASS 是 BGR 顺序)。"""
|
||
assert _hex_to_ass_color("#ffffff") == "&HFFFFFF"
|
||
|
||
def test_hex_to_ass_color_black(self):
|
||
"""#000000 → &H000000。"""
|
||
assert _hex_to_ass_color("#000000") == "&H000000"
|
||
|
||
def test_hex_to_ass_color_red(self):
|
||
"""#ff0000 红 → &H0000FF(B=00, G=00, R=FF)。"""
|
||
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
|
||
|
||
def test_hex_to_ass_color_blue(self):
|
||
"""#0000ff 蓝 → &HFF0000(B=FF, G=00, R=00)。"""
|
||
assert _hex_to_ass_color("#0000ff") == "&HFF0000"
|
||
|
||
def test_hex_to_ass_color_no_hash(self):
|
||
"""不带 # 的颜色值也能解析。"""
|
||
assert _hex_to_ass_color("ff0000") == "&H0000FF"
|
||
|
||
def test_position_to_ass_alignment_top(self):
|
||
"""top → 8(顶部居中)。"""
|
||
assert _position_to_ass_alignment("top") == 8
|
||
|
||
def test_position_to_ass_alignment_center(self):
|
||
"""center → 5(居中)。"""
|
||
assert _position_to_ass_alignment("center") == 5
|
||
|
||
def test_position_to_ass_alignment_bottom(self):
|
||
"""bottom → 2(底部居中)。"""
|
||
assert _position_to_ass_alignment("bottom") == 2
|
||
|
||
def test_generate_ass_with_title_only(self, tmp_path):
|
||
"""只有标题时生成 ASS 文件。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
result = generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=10.0,
|
||
title_text="测试标题",
|
||
title_config={
|
||
"enabled": True,
|
||
"font": "思源黑体",
|
||
"size": 48,
|
||
"color": "#ffffff",
|
||
"bold": True,
|
||
"position": "top",
|
||
"stroke": {"enabled": True, "color": "#000000", "width": 2},
|
||
"shadow": {"enabled": True, "blur": 4, "offset_x": 2, "offset_y": 2},
|
||
},
|
||
)
|
||
|
||
assert result == ass_path
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
assert "[Script Info]" in content
|
||
assert "PlayResX: 1280" in content
|
||
assert "PlayResY: 720" in content
|
||
assert "[V4+ Styles]" in content
|
||
assert "TitleStyle" in content
|
||
assert "测试标题" in content
|
||
assert "Dialogue:" in content
|
||
|
||
def test_generate_ass_with_subtitle_only(self, tmp_path):
|
||
"""只有字幕时生成 ASS 文件。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
result = generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=10.0,
|
||
subtitle_text="测试字幕内容",
|
||
subtitle_config={
|
||
"enabled": True,
|
||
"font": "思源黑体",
|
||
"size": 24,
|
||
"color": "#ffffff",
|
||
"position": "bottom",
|
||
},
|
||
)
|
||
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
assert "SubtitleStyle" in content
|
||
assert "测试字幕内容" in content
|
||
assert "Dialogue:" in content
|
||
|
||
def test_generate_ass_with_both_title_and_subtitle(self, tmp_path):
|
||
"""同时有标题和字幕。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=10.0,
|
||
title_text="大标题",
|
||
title_config={"enabled": True, "position": "top"},
|
||
subtitle_text="底部字幕",
|
||
subtitle_config={"enabled": True, "position": "bottom"},
|
||
)
|
||
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
assert "TitleStyle" in content
|
||
assert "SubtitleStyle" in content
|
||
assert "大标题" in content
|
||
assert "底部字幕" in content
|
||
# 两条 Dialogue 行
|
||
assert content.count("Dialogue:") == 2
|
||
|
||
def test_generate_ass_disabled_returns_empty(self, tmp_path):
|
||
"""标题和字幕都禁用时返回空文件。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=10.0,
|
||
title_text="不显示",
|
||
title_config={"enabled": False},
|
||
subtitle_text="也不显示",
|
||
subtitle_config={"enabled": False},
|
||
)
|
||
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
assert content == ""
|
||
|
||
def test_generate_ass_empty_text_returns_empty(self, tmp_path):
|
||
"""文本为空时不生成字幕。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=10.0,
|
||
title_text="",
|
||
title_config={"enabled": True},
|
||
subtitle_text=" ",
|
||
subtitle_config={"enabled": True},
|
||
)
|
||
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
assert content == ""
|
||
|
||
def test_generate_ass_time_format(self, tmp_path):
|
||
"""验证 ASS 时间格式正确(H:MM:SS.cc)。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=125.5, # 2分5.5秒
|
||
title_text="测试",
|
||
title_config={"enabled": True},
|
||
)
|
||
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
# 结束时间应该是 0:02:05.50
|
||
assert "0:02:05.50" in content
|
||
|
||
def test_ass_text_escape_newlines(self, tmp_path):
|
||
"""换行符转义为 ASS 的 \\N。"""
|
||
ass_path = tmp_path / "test.ass"
|
||
generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=10.0,
|
||
title_text="第一行\n第二行",
|
||
title_config={"enabled": True},
|
||
)
|
||
|
||
content = ass_path.read_text(encoding="utf-8")
|
||
assert "第一行\\N第二行" in content
|
||
|
||
|
||
# ── 测试 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_single_clip(self):
|
||
"""单clip正常渲染(走直通路径)。"""
|
||
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, "_render_pass_through") as mock_pass,
|
||
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_pass.assert_called_once()
|
||
|
||
def test_render_success_multi_clips(self):
|
||
"""多clip正常渲染(走完整filter_complex路径)。"""
|
||
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),
|
||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
assert isinstance(result, RenderResult)
|
||
assert result.duration == 5.5
|
||
assert result.file_size == 2048
|
||
assert result.width == 1280
|
||
assert result.height == 720
|
||
mock_exec.assert_called_once()
|