test(unit): 第67波 - trim引擎 + concat引擎 + subtitle_render引擎配置解析 (+112) #863
+225
-226
@@ -1,271 +1,270 @@
|
||||
"""
|
||||
视频拼接引擎配置与纯逻辑测试.
|
||||
|
||||
覆盖 ConcatSegment.from_dict / ConcatConfig.from_config_dict / has_effect / total_segments 等纯逻辑.
|
||||
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
"""拼接引擎单元测试 - 配置解析等纯逻辑."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.concat_engine import ConcatConfig, ConcatSegment
|
||||
|
||||
|
||||
class TestConcatSegmentFromDict:
|
||||
"""ConcatSegment.from_dict 构造逻辑."""
|
||||
class TestConcatSegmentDefaults:
|
||||
"""ConcatSegment 默认值测试."""
|
||||
|
||||
def test_basic(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "/tmp/a.mp4"})
|
||||
assert seg.video_path == "/tmp/a.mp4"
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
seg = ConcatSegment(video_path="/a.mp4")
|
||||
assert seg.video_path == "/a.mp4"
|
||||
assert seg.start_time == 0.0
|
||||
assert seg.duration == 0.0
|
||||
assert seg.has_audio is True
|
||||
|
||||
def test_full_fields(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/b.mp4",
|
||||
"start_time": 5.5,
|
||||
"duration": 10.0,
|
||||
"has_audio": False,
|
||||
}
|
||||
)
|
||||
assert seg.video_path == "/tmp/b.mp4"
|
||||
assert seg.start_time == 5.5
|
||||
|
||||
class TestConcatSegmentFromDict:
|
||||
"""ConcatSegment.from_dict 测试."""
|
||||
|
||||
def test_basic_path(self):
|
||||
"""基本路径."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "/a.mp4"})
|
||||
assert seg.video_path == "/a.mp4"
|
||||
assert seg.start_time == 0.0
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_custom_start_time(self):
|
||||
"""自定义开始时间."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": 5.0,
|
||||
})
|
||||
assert seg.start_time == 5.0
|
||||
|
||||
def test_custom_duration(self):
|
||||
"""自定义时长."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"duration": 10.0,
|
||||
})
|
||||
assert seg.duration == 10.0
|
||||
|
||||
def test_start_time_negative_clamped(self):
|
||||
"""负开始时间钳制到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": -5.0,
|
||||
})
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_duration_negative_clamped(self):
|
||||
"""负时长钳制到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"duration": -3.0,
|
||||
})
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_invalid_start_time_falls_back(self):
|
||||
"""无效start_time回退到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": "invalid",
|
||||
})
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_invalid_duration_falls_back(self):
|
||||
"""无效duration回退到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"duration": "not_a_number",
|
||||
})
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_no_audio(self):
|
||||
"""无音频."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"has_audio": False,
|
||||
})
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_negative_start_time_clamped(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"start_time": -1.0,
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
def test_full_config(self):
|
||||
"""完整配置."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/video.mp4",
|
||||
"start_time": 2.5,
|
||||
"duration": 15.0,
|
||||
"has_audio": False,
|
||||
})
|
||||
assert seg.video_path == "/video.mp4"
|
||||
assert seg.start_time == 2.5
|
||||
assert seg.duration == 15.0
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_negative_duration_clamped(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"duration": -5.0,
|
||||
}
|
||||
)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_invalid_start_time_type_falls_back(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"start_time": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
class TestConcatConfigDefaults:
|
||||
"""ConcatConfig 默认值测试."""
|
||||
|
||||
def test_invalid_duration_type_falls_back(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"duration": "abc",
|
||||
}
|
||||
)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_start_time_none_falls_back(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/tmp/a.mp4",
|
||||
"start_time": None,
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_empty_video_path_stored(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": ""})
|
||||
assert seg.video_path == ""
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
config = ConcatConfig()
|
||||
assert config.segments == []
|
||||
assert config.output_width == 0
|
||||
assert config.output_height == 0
|
||||
assert config.output_fps == 0.0
|
||||
assert config.force_reencode is False
|
||||
assert config.transition == "none"
|
||||
assert config.transition_duration == 0.3
|
||||
|
||||
|
||||
class TestConcatConfigFromConfigDict:
|
||||
"""ConcatConfig.from_config_dict 构造逻辑."""
|
||||
"""ConcatConfig.from_config_dict 测试."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
cfg = ConcatConfig.from_config_dict(None)
|
||||
assert cfg.segments == []
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
assert cfg.output_fps == 0.0
|
||||
assert cfg.force_reencode is False
|
||||
"""None返回默认配置."""
|
||||
config = ConcatConfig.from_config_dict(None)
|
||||
assert config.segments == []
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_non_dict_returns_default(self):
|
||||
cfg = ConcatConfig.from_config_dict("not a dict")
|
||||
assert cfg.segments == []
|
||||
"""空dict返回默认."""
|
||||
config = ConcatConfig.from_config_dict({})
|
||||
assert config.segments == []
|
||||
|
||||
def test_single_segment(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/tmp/a.mp4", "duration": 5.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 1
|
||||
assert cfg.segments[0].video_path == "/tmp/a.mp4"
|
||||
assert cfg.segments[0].duration == 5.0
|
||||
"""单片段."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
})
|
||||
assert len(config.segments) == 1
|
||||
assert config.segments[0].video_path == "/a.mp4"
|
||||
|
||||
def test_multiple_segments(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/tmp/a.mp4"},
|
||||
{"video_path": "/tmp/b.mp4", "start_time": 2.0},
|
||||
{"video_path": "/tmp/c.mp4", "duration": 3.0, "has_audio": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 3
|
||||
assert cfg.segments[0].video_path == "/tmp/a.mp4"
|
||||
assert cfg.segments[1].start_time == 2.0
|
||||
assert cfg.segments[2].has_audio is False
|
||||
"""多片段."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4", "start_time": 1.0},
|
||||
{"video_path": "/b.mp4", "duration": 5.0},
|
||||
{"video_path": "/c.mp4"},
|
||||
],
|
||||
})
|
||||
assert len(config.segments) == 3
|
||||
assert config.segments[0].start_time == 1.0
|
||||
assert config.segments[1].duration == 5.0
|
||||
|
||||
def test_invalid_segments_filtered(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/tmp/valid.mp4"},
|
||||
{"video_path": ""}, # 空路径被过滤
|
||||
{"not_video_path": "xxx"}, # 没有video_path被过滤
|
||||
"not_a_dict", # 不是dict被过滤
|
||||
None, # None被过滤
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 1
|
||||
assert cfg.segments[0].video_path == "/tmp/valid.mp4"
|
||||
def test_skips_no_path(self):
|
||||
"""跳过无video_path的片段."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"other": "value"},
|
||||
{"video_path": ""},
|
||||
],
|
||||
})
|
||||
assert len(config.segments) == 1
|
||||
|
||||
def test_segments_not_a_list(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": "not_a_list",
|
||||
}
|
||||
)
|
||||
assert cfg.segments == []
|
||||
def test_segments_not_list_ignored(self):
|
||||
"""segments不是列表忽略."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": "not_a_list",
|
||||
})
|
||||
assert config.segments == []
|
||||
|
||||
def test_output_params(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30.0,
|
||||
"force_reencode": True,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 1920
|
||||
assert cfg.output_height == 1080
|
||||
assert cfg.output_fps == 30.0
|
||||
assert cfg.force_reencode is True
|
||||
def test_output_size(self):
|
||||
"""输出尺寸."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
})
|
||||
assert config.output_width == 1920
|
||||
assert config.output_height == 1080
|
||||
|
||||
def test_negative_output_params_clamped(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"output_width": -100,
|
||||
"output_height": -50,
|
||||
"output_fps": -1.0,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
assert cfg.output_fps == 0.0
|
||||
def test_negative_output_size_clamped(self):
|
||||
"""负输出尺寸钳制到0."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": -100,
|
||||
"output_height": -50,
|
||||
})
|
||||
assert config.output_width == 0
|
||||
assert config.output_height == 0
|
||||
|
||||
def test_invalid_output_params_fall_back(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"output_width": "abc",
|
||||
"output_height": None,
|
||||
"output_fps": "xyz",
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
assert cfg.output_fps == 0.0
|
||||
def test_invalid_output_size_falls_back(self):
|
||||
"""无效输出尺寸回退."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": "wide",
|
||||
"output_fps": "sixty",
|
||||
})
|
||||
assert config.output_width == 0
|
||||
assert config.output_fps == 0.0
|
||||
|
||||
def test_output_fps(self):
|
||||
"""输出帧率."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_fps": 60.0,
|
||||
})
|
||||
assert config.output_fps == 60.0
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重新编码."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"force_reencode": True,
|
||||
})
|
||||
assert config.force_reencode is True
|
||||
|
||||
def test_transition_config(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"transition": "crossfade",
|
||||
"transition_duration": 1.0,
|
||||
}
|
||||
)
|
||||
assert cfg.transition == "crossfade"
|
||||
assert cfg.transition_duration == 1.0
|
||||
"""转场配置."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"transition": "crossfade",
|
||||
"transition_duration": 1.0,
|
||||
})
|
||||
assert config.transition == "crossfade"
|
||||
assert config.transition_duration == 1.0
|
||||
|
||||
def test_transition_duration_minimum(self):
|
||||
"""transition_duration 不能小于 0.1."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [],
|
||||
"transition_duration": 0.01,
|
||||
}
|
||||
)
|
||||
assert cfg.transition_duration >= 0.1
|
||||
|
||||
def test_default_values(self):
|
||||
cfg = ConcatConfig.from_config_dict({"segments": []})
|
||||
assert cfg.transition == "none"
|
||||
assert cfg.transition_duration == 0.3
|
||||
assert cfg.force_reencode is False
|
||||
def test_non_dict_config_returns_default(self):
|
||||
"""非dict配置返回默认."""
|
||||
config = ConcatConfig.from_config_dict("not_a_dict")
|
||||
assert config.segments == []
|
||||
|
||||
|
||||
class TestConcatConfigProperties:
|
||||
"""has_effect / total_segments 属性."""
|
||||
class TestHasEffect:
|
||||
"""has_effect 属性测试."""
|
||||
|
||||
def test_has_effect_two_or_more_valid(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/tmp/a.mp4"),
|
||||
ConcatSegment(video_path="/tmp/b.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is True
|
||||
def test_no_segments_no_effect(self):
|
||||
"""无片段无效果."""
|
||||
config = ConcatConfig()
|
||||
assert config.has_effect is False
|
||||
|
||||
def test_no_effect_one_segment(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/tmp/a.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is False
|
||||
def test_one_segment_no_effect(self):
|
||||
"""单片段无效果(拼接至少需要2段)."""
|
||||
config = ConcatConfig(segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
])
|
||||
assert config.has_effect is False
|
||||
|
||||
def test_no_effect_zero_segments(self):
|
||||
cfg = ConcatConfig(segments=[])
|
||||
assert cfg.has_effect is False
|
||||
def test_two_segments_has_effect(self):
|
||||
"""两段及以上有效果."""
|
||||
config = ConcatConfig(segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
ConcatSegment(video_path="/b.mp4"),
|
||||
])
|
||||
assert config.has_effect is True
|
||||
|
||||
def test_no_effect_empty_paths(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path=""),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_total_segments(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/tmp/a.mp4"),
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path="/tmp/b.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.total_segments == 2
|
||||
class TestTotalSegments:
|
||||
"""total_segments 属性测试."""
|
||||
|
||||
def test_total_segments_empty(self):
|
||||
cfg = ConcatConfig(segments=[])
|
||||
assert cfg.total_segments == 0
|
||||
def test_no_segments(self):
|
||||
"""零片段."""
|
||||
config = ConcatConfig()
|
||||
assert config.total_segments == 0
|
||||
|
||||
def test_three_segments(self):
|
||||
"""三个片段."""
|
||||
config = ConcatConfig(segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
ConcatSegment(video_path="/b.mp4"),
|
||||
ConcatSegment(video_path="/c.mp4"),
|
||||
])
|
||||
assert config.total_segments == 3
|
||||
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
"""字幕渲染引擎单元测试 - 工具函数+样式配置等纯逻辑."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.subtitle_render_engine import (
|
||||
SubtitleStyle,
|
||||
_escape_ass_text,
|
||||
_format_ass_time,
|
||||
_hex_to_ass_bgr,
|
||||
_hex_to_ass_color,
|
||||
_opacity_to_ass_alpha,
|
||||
_wrap_text,
|
||||
)
|
||||
|
||||
|
||||
# ── 颜色转换测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHexToAssColor:
|
||||
"""_hex_to_ass_color 测试."""
|
||||
|
||||
def test_white(self):
|
||||
"""白色."""
|
||||
assert _hex_to_ass_color("#FFFFFF") == "&H00FFFFFF"
|
||||
|
||||
def test_black(self):
|
||||
"""黑色."""
|
||||
assert _hex_to_ass_color("#000000") == "&H00000000"
|
||||
|
||||
def test_red(self):
|
||||
"""红色 → BGR: 蓝绿红."""
|
||||
assert _hex_to_ass_color("#FF0000") == "&H000000FF"
|
||||
|
||||
def test_green(self):
|
||||
"""绿色."""
|
||||
assert _hex_to_ass_color("#00FF00") == "&H0000FF00"
|
||||
|
||||
def test_blue(self):
|
||||
"""蓝色."""
|
||||
assert _hex_to_ass_color("#0000FF") == "&H00FF0000"
|
||||
|
||||
def test_no_hash_prefix(self):
|
||||
"""不带#号."""
|
||||
assert _hex_to_ass_color("FF0000") == "&H000000FF"
|
||||
|
||||
def test_invalid_length(self):
|
||||
"""长度不对返回默认白色."""
|
||||
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF"
|
||||
assert _hex_to_ass_color("#FF") == "&H00FFFFFF"
|
||||
assert _hex_to_ass_color("") == "&H00FFFFFF"
|
||||
|
||||
def test_lowercase_input(self):
|
||||
"""小写输入转为大写输出."""
|
||||
assert _hex_to_ass_color("#aabbcc") == "&H00CCBBAA"
|
||||
|
||||
|
||||
class TestHexToAssBgr:
|
||||
"""_hex_to_ass_bgr 测试."""
|
||||
|
||||
def test_white(self):
|
||||
"""白色BGR."""
|
||||
assert _hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
|
||||
|
||||
def test_red_bgr(self):
|
||||
"""红色 → BGR = 0000FF."""
|
||||
assert _hex_to_ass_bgr("#FF0000") == "0000FF"
|
||||
|
||||
def test_blue_bgr(self):
|
||||
"""蓝色 → BGR = FF0000."""
|
||||
assert _hex_to_ass_bgr("#0000FF") == "FF0000"
|
||||
|
||||
def test_invalid_length(self):
|
||||
"""长度不对返回默认."""
|
||||
assert _hex_to_ass_bgr("#FF") == "FFFFFF"
|
||||
|
||||
|
||||
class TestOpacityToAssAlpha:
|
||||
"""_opacity_to_ass_alpha 测试."""
|
||||
|
||||
def test_fully_opaque(self):
|
||||
"""完全不透明 → 00."""
|
||||
assert _opacity_to_ass_alpha(1.0) == "00"
|
||||
|
||||
def test_fully_transparent(self):
|
||||
"""完全透明 → FF."""
|
||||
assert _opacity_to_ass_alpha(0.0) == "FF"
|
||||
|
||||
def test_half(self):
|
||||
"""50% → 128 → 80."""
|
||||
assert _opacity_to_ass_alpha(0.5) == "80"
|
||||
|
||||
def test_quarter(self):
|
||||
"""75%不透明 → 64 → 40."""
|
||||
assert _opacity_to_ass_alpha(0.75) == "40"
|
||||
|
||||
|
||||
# ── 文本处理测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEscapeAssText:
|
||||
"""_escape_ass_text 转义测试."""
|
||||
|
||||
def test_normal_text_unchanged(self):
|
||||
"""普通文本不变."""
|
||||
assert _escape_ass_text("hello world") == "hello world"
|
||||
|
||||
def test_newline_converted(self):
|
||||
"""换行转成\\N."""
|
||||
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||||
|
||||
def test_crlf_converted(self):
|
||||
"""\\r\\n转成\\N."""
|
||||
assert _escape_ass_text("line1\r\nline2") == "line1\\Nline2"
|
||||
|
||||
def test_carriage_return_converted(self):
|
||||
"""\\r转成\\N."""
|
||||
assert _escape_ass_text("line1\rline2") == "line1\\Nline2"
|
||||
|
||||
def test_curly_braces_replaced(self):
|
||||
"""花括号替换成圆括号(ASS控制符)."""
|
||||
assert _escape_ass_text("{text}") == "(text)"
|
||||
|
||||
def test_mixed_special_chars(self):
|
||||
"""混合特殊字符."""
|
||||
text = "hello\n{world}\r\nend"
|
||||
result = _escape_ass_text(text)
|
||||
assert "\\N" in result
|
||||
assert "{" not in result
|
||||
assert "}" not in result
|
||||
assert "(world)" in result
|
||||
|
||||
|
||||
class TestFormatAssTime:
|
||||
"""_format_ass_time 时间格式化测试."""
|
||||
|
||||
def test_zero(self):
|
||||
"""0秒."""
|
||||
assert _format_ass_time(0) == "0:00:00.00"
|
||||
|
||||
def test_seconds_only(self):
|
||||
"""只有秒."""
|
||||
assert _format_ass_time(5.5) == "0:00:05.50"
|
||||
|
||||
def test_minutes_and_seconds(self):
|
||||
"""分+秒."""
|
||||
assert _format_ass_time(125.5) == "0:02:05.50"
|
||||
|
||||
def test_hours_minutes_seconds(self):
|
||||
"""时+分+秒."""
|
||||
assert _format_ass_time(3725.25) == "1:02:05.25"
|
||||
|
||||
def test_exactly_one_hour(self):
|
||||
"""刚好1小时."""
|
||||
assert _format_ass_time(3600.0) == "1:00:00.00"
|
||||
|
||||
def test_single_digit_minute(self):
|
||||
"""分钟补零."""
|
||||
result = _format_ass_time(65.0)
|
||||
parts = result.split(":")
|
||||
assert parts[1] == "01"
|
||||
|
||||
def test_always_two_decimal_places(self):
|
||||
"""总是两位小数."""
|
||||
result = _format_ass_time(3.0)
|
||||
assert result.endswith(".00")
|
||||
|
||||
|
||||
class TestWrapText:
|
||||
"""_wrap_text 换行测试."""
|
||||
|
||||
def test_short_text_no_wrap(self):
|
||||
"""短文本不换行."""
|
||||
result = _wrap_text("hello", 10)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "hello"
|
||||
|
||||
def test_exact_length_no_wrap(self):
|
||||
"""刚好长度不换行."""
|
||||
text = "abcdefghij" # 10 chars
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 1
|
||||
assert result[0] == text
|
||||
|
||||
def test_simple_wrap(self):
|
||||
"""简单换行."""
|
||||
text = "abcdefghijklmnopqrst" # 20 chars
|
||||
result = _wrap_text(text, 10)
|
||||
assert len(result) == 2
|
||||
assert len(result[0]) == 10
|
||||
assert len(result[1]) == 10
|
||||
|
||||
def test_uneven_wrap(self):
|
||||
"""不均等换行."""
|
||||
text = "abcdefghijklm" # 13 chars
|
||||
result = _wrap_text(text, 5)
|
||||
assert len(result) == 3
|
||||
assert result[0] == "abcde"
|
||||
assert result[1] == "fghij"
|
||||
assert result[2] == "klm"
|
||||
|
||||
def test_chinese_text_wrap(self):
|
||||
"""中文文本换行(按字符数)."""
|
||||
text = "一二三四五六七八九十"
|
||||
result = _wrap_text(text, 5)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "一二三四五"
|
||||
assert result[1] == "六七八九十"
|
||||
|
||||
|
||||
# ── SubtitleStyle 测试 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleStyleDefaults:
|
||||
"""SubtitleStyle 默认值测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
style = SubtitleStyle()
|
||||
assert style.font_size > 0
|
||||
assert style.bold is False
|
||||
assert style.italic is False
|
||||
assert style.stroke_enabled is True
|
||||
assert style.shadow_enabled is False
|
||||
assert style.background_enabled is False
|
||||
assert style.fade_in == 0.0
|
||||
assert style.fade_out == 0.0
|
||||
assert style.animation_type == "none"
|
||||
|
||||
|
||||
class TestSubtitleStyleFromDict:
|
||||
"""SubtitleStyle.from_dict 测试."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
"""None返回默认样式."""
|
||||
style = SubtitleStyle.from_dict(None)
|
||||
assert isinstance(style, SubtitleStyle)
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
"""空dict返回默认."""
|
||||
style = SubtitleStyle.from_dict({})
|
||||
assert isinstance(style, SubtitleStyle)
|
||||
|
||||
def test_custom_font_size(self):
|
||||
"""自定义字号."""
|
||||
style = SubtitleStyle.from_dict({"size": 48})
|
||||
assert style.font_size == 48
|
||||
|
||||
def test_custom_color(self):
|
||||
"""自定义颜色."""
|
||||
style = SubtitleStyle.from_dict({"color": "#FF0000"})
|
||||
assert style.font_color == "#FF0000"
|
||||
|
||||
def test_bold_enabled(self):
|
||||
"""启用粗体."""
|
||||
style = SubtitleStyle.from_dict({"bold": True})
|
||||
assert style.bold is True
|
||||
|
||||
def test_stroke_disabled(self):
|
||||
"""禁用描边."""
|
||||
style = SubtitleStyle.from_dict({"stroke_enabled": False})
|
||||
assert style.stroke_enabled is False
|
||||
|
||||
def test_background_enabled(self):
|
||||
"""启用背景框."""
|
||||
style = SubtitleStyle.from_dict({"background_enabled": True})
|
||||
assert style.background_enabled is True
|
||||
|
||||
def test_background_opacity_clamped(self):
|
||||
"""背景透明度钳制."""
|
||||
style = SubtitleStyle.from_dict({
|
||||
"background_enabled": True,
|
||||
"background_opacity": 2.0,
|
||||
})
|
||||
assert style.background_opacity == 1.0
|
||||
|
||||
def test_invalid_position_falls_back(self):
|
||||
"""无效位置回退到默认."""
|
||||
style = SubtitleStyle.from_dict({"position": "invalid_pos"})
|
||||
# 回退到默认位置
|
||||
assert style.position is not None
|
||||
|
||||
def test_fade_in_non_negative(self):
|
||||
"""淡入时长不能为负."""
|
||||
style = SubtitleStyle.from_dict({"fade_in": -1.0})
|
||||
assert style.fade_in == 0.0
|
||||
|
||||
def test_custom_animation(self):
|
||||
"""自定义动画."""
|
||||
style = SubtitleStyle.from_dict({"animation_type": "fade"})
|
||||
assert style.animation_type == "fade"
|
||||
|
||||
|
||||
class TestSubtitleStyleProperties:
|
||||
"""SubtitleStyle 属性测试."""
|
||||
|
||||
def test_ass_font_color_format(self):
|
||||
"""ass_font_color格式正确."""
|
||||
style = SubtitleStyle(font_color="#FF0000")
|
||||
result = style.ass_font_color
|
||||
assert result.startswith("&H")
|
||||
assert len(result) == 10 # &H + AABBGGRR = 10 chars
|
||||
|
||||
def test_ass_background_color_format(self):
|
||||
"""背景颜色格式正确."""
|
||||
style = SubtitleStyle(
|
||||
background_enabled=True,
|
||||
background_color="#000000",
|
||||
background_opacity=0.5,
|
||||
)
|
||||
result = style.ass_background_color
|
||||
assert result.startswith("&H")
|
||||
|
||||
def test_alignment_is_int(self):
|
||||
"""alignment是整数."""
|
||||
style = SubtitleStyle()
|
||||
assert isinstance(style.alignment, int)
|
||||
+236
-233
@@ -1,268 +1,271 @@
|
||||
"""裁剪引擎单元测试."""
|
||||
"""裁剪引擎单元测试 - 配置解析+推导等纯逻辑."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
# 确保 apps/worker 在路径中
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
|
||||
import pytest
|
||||
|
||||
from video_processing.trim_engine import (
|
||||
MIN_TRIM_DURATION,
|
||||
TrimConfig,
|
||||
TrimEngine,
|
||||
TrimSegment,
|
||||
extract_trim_from_clip_config,
|
||||
)
|
||||
from video_processing.trim_engine import MIN_TRIM_DURATION, TrimConfig, TrimSegment
|
||||
|
||||
|
||||
class TestTrimConfig(unittest.TestCase):
|
||||
"""TrimConfig 单元测试."""
|
||||
class TestTrimConfigFromDict:
|
||||
"""TrimConfig.from_dict 解析测试."""
|
||||
|
||||
def test_from_dict_none(self):
|
||||
"""空字典返回 None(不裁剪)."""
|
||||
self.assertIsNone(TrimConfig.from_dict(None))
|
||||
self.assertIsNone(TrimConfig.from_dict({}))
|
||||
def test_none_returns_none(self):
|
||||
"""None返回None(不裁剪)."""
|
||||
assert TrimConfig.from_dict(None) is None
|
||||
|
||||
def test_from_dict_with_start(self):
|
||||
"""只有 start_time."""
|
||||
cfg = TrimConfig.from_dict({"start_time": 5.0})
|
||||
self.assertIsNotNone(cfg)
|
||||
self.assertEqual(cfg.start_time, 5.0)
|
||||
self.assertEqual(cfg.end_time, 0.0)
|
||||
self.assertEqual(cfg.duration, 0.0)
|
||||
def test_empty_dict_returns_none(self):
|
||||
"""空dict返回None."""
|
||||
assert TrimConfig.from_dict({}) is None
|
||||
|
||||
def test_from_dict_with_duration(self):
|
||||
"""只有 duration."""
|
||||
cfg = TrimConfig.from_dict({"duration": 10.0})
|
||||
self.assertIsNotNone(cfg)
|
||||
self.assertEqual(cfg.start_time, 0.0)
|
||||
self.assertEqual(cfg.duration, 10.0)
|
||||
def test_all_zero_returns_none(self):
|
||||
"""全零返回None."""
|
||||
assert TrimConfig.from_dict({
|
||||
"start_time": 0,
|
||||
"end_time": 0,
|
||||
"duration": 0,
|
||||
}) is None
|
||||
|
||||
def test_resolve_start_and_end(self):
|
||||
"""start + end 推导 duration."""
|
||||
cfg = TrimConfig(start_time=5.0, end_time=15.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 5.0)
|
||||
self.assertEqual(resolved.end_time, 15.0)
|
||||
self.assertAlmostEqual(resolved.duration, 10.0, places=3)
|
||||
self.assertTrue(resolved.is_valid)
|
||||
def test_start_only(self):
|
||||
"""只有start_time有效."""
|
||||
config = TrimConfig.from_dict({"start_time": 5.0})
|
||||
assert config is not None
|
||||
assert config.start_time == 5.0
|
||||
assert config.end_time == 0
|
||||
assert config.duration == 0
|
||||
|
||||
def test_resolve_start_and_duration(self):
|
||||
"""start + duration 推导 end."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 5.0)
|
||||
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
|
||||
self.assertEqual(resolved.duration, 10.0)
|
||||
def test_duration_only(self):
|
||||
"""只有duration有效."""
|
||||
config = TrimConfig.from_dict({"duration": 10.0})
|
||||
assert config is not None
|
||||
assert config.start_time == 0
|
||||
assert config.duration == 10.0
|
||||
|
||||
def test_resolve_end_and_duration(self):
|
||||
"""end + duration 推导 start."""
|
||||
cfg = TrimConfig(end_time=20.0, duration=8.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertAlmostEqual(resolved.start_time, 12.0, places=3)
|
||||
self.assertEqual(resolved.end_time, 20.0)
|
||||
self.assertEqual(resolved.duration, 8.0)
|
||||
def test_start_and_duration(self):
|
||||
"""start + duration."""
|
||||
config = TrimConfig.from_dict({"start_time": 2.0, "duration": 5.0})
|
||||
assert config is not None
|
||||
assert config.start_time == 2.0
|
||||
assert config.duration == 5.0
|
||||
|
||||
def test_resolve_only_start(self):
|
||||
"""只有 start → 取到末尾."""
|
||||
cfg = TrimConfig(start_time=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 10.0)
|
||||
self.assertEqual(resolved.end_time, 30.0)
|
||||
self.assertAlmostEqual(resolved.duration, 20.0, places=3)
|
||||
def test_start_and_end(self):
|
||||
"""start + end."""
|
||||
config = TrimConfig.from_dict({"start_time": 1.0, "end_time": 4.0})
|
||||
assert config is not None
|
||||
assert config.start_time == 1.0
|
||||
assert config.end_time == 4.0
|
||||
|
||||
def test_resolve_only_duration(self):
|
||||
"""只有 duration → 从开头取."""
|
||||
cfg = TrimConfig(duration=15.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 0.0)
|
||||
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
|
||||
self.assertEqual(resolved.duration, 15.0)
|
||||
def test_end_and_duration(self):
|
||||
"""end + duration."""
|
||||
config = TrimConfig.from_dict({"end_time": 10.0, "duration": 3.0})
|
||||
assert config is not None
|
||||
assert config.end_time == 10.0
|
||||
assert config.duration == 3.0
|
||||
|
||||
def test_boundary_clamp_end(self):
|
||||
"""end 超出素材时长 → 钳制."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=30.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=20.0)
|
||||
self.assertEqual(resolved.start_time, 5.0)
|
||||
self.assertEqual(resolved.end_time, 20.0)
|
||||
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
|
||||
def test_string_values_converted(self):
|
||||
"""字符串值会被转换."""
|
||||
config = TrimConfig.from_dict({
|
||||
"start_time": "5.0",
|
||||
"duration": "10.0",
|
||||
})
|
||||
assert config is not None
|
||||
assert config.start_time == 5.0
|
||||
assert config.duration == 10.0
|
||||
|
||||
def test_boundary_clamp_start_negative(self):
|
||||
"""start 为负 → 钳制到 0."""
|
||||
cfg = TrimConfig(start_time=-5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 0.0)
|
||||
self.assertAlmostEqual(resolved.end_time, 10.0, places=3)
|
||||
self.assertEqual(resolved.duration, 10.0)
|
||||
def test_falsy_start_with_duration(self):
|
||||
"""start=0 + duration>0有效."""
|
||||
config = TrimConfig.from_dict({"start_time": 0, "duration": 5.0})
|
||||
assert config is not None
|
||||
assert config.start_time == 0.0
|
||||
assert config.duration == 5.0
|
||||
|
||||
def test_boundary_start_past_end(self):
|
||||
"""start 超过素材总时长 → 钳制到末尾最小片段."""
|
||||
cfg = TrimConfig(start_time=50.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertTrue(resolved.start_time < 30.0)
|
||||
self.assertEqual(resolved.end_time, 30.0)
|
||||
self.assertTrue(resolved.duration >= MIN_TRIM_DURATION)
|
||||
|
||||
def test_invalid_end_before_start(self):
|
||||
"""end <= start → 无效."""
|
||||
cfg = TrimConfig(start_time=15.0, end_time=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertFalse(resolved.is_valid)
|
||||
class TestValidateAndResolve:
|
||||
"""validate_and_resolve 推导测试."""
|
||||
|
||||
def test_zero_duration_invalid(self):
|
||||
"""duration 为 0 → 无效."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=0.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
# 只有 start 没有 duration → 会被推导为取到末尾
|
||||
self.assertTrue(resolved.is_valid)
|
||||
self.assertEqual(resolved.end_time, 30.0)
|
||||
def test_start_plus_end(self):
|
||||
"""start + end → 推导duration."""
|
||||
config = TrimConfig(start_time=2.0, end_time=7.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 2.0
|
||||
assert resolved.end_time == 7.0
|
||||
assert resolved.duration == 5.0
|
||||
|
||||
def test_is_noop(self):
|
||||
"""is_noop 判断."""
|
||||
noop = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
self.assertTrue(noop.is_noop)
|
||||
def test_start_plus_duration(self):
|
||||
"""start + duration → 推导end."""
|
||||
config = TrimConfig(start_time=3.0, duration=10.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 3.0
|
||||
assert resolved.duration == 10.0
|
||||
assert resolved.end_time == 13.0
|
||||
|
||||
not_noop = TrimConfig(start_time=5.0, duration=10.0)
|
||||
self.assertFalse(not_noop.is_noop)
|
||||
def test_end_plus_duration(self):
|
||||
"""end + duration → 推导start."""
|
||||
config = TrimConfig(end_time=15.0, duration=5.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.end_time == 15.0
|
||||
assert resolved.duration == 5.0
|
||||
assert resolved.start_time == 10.0
|
||||
|
||||
def test_start_only_takes_to_end(self):
|
||||
"""只有start → 取到素材末尾."""
|
||||
config = TrimConfig(start_time=50.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 50.0
|
||||
assert resolved.end_time == 60.0
|
||||
assert resolved.duration == 10.0
|
||||
|
||||
def test_end_only_takes_from_start(self):
|
||||
"""只有end → 从开头取."""
|
||||
config = TrimConfig(end_time=20.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 20.0
|
||||
assert resolved.duration == 20.0
|
||||
|
||||
def test_end_before_start_invalid(self):
|
||||
"""end < start → 无效(0时长)."""
|
||||
config = TrimConfig(start_time=10.0, end_time=5.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.duration == 0.0
|
||||
assert resolved.is_valid is False
|
||||
|
||||
def test_negative_start_clamped(self):
|
||||
"""负start钳制到0."""
|
||||
config = TrimConfig(start_time=-5.0, duration=10.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 10.0
|
||||
|
||||
def test_end_beyond_asset_clamped(self):
|
||||
"""end超过素材时长钳制."""
|
||||
config = TrimConfig(start_time=50.0, duration=20.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.end_time == 60.0
|
||||
assert resolved.duration == 10.0
|
||||
|
||||
def test_start_beyond_asset_clamped(self):
|
||||
"""start超过素材时长 → 钳制到末尾保留MIN_TRIM."""
|
||||
config = TrimConfig(start_time=100.0, duration=5.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 60.0 - MIN_TRIM_DURATION
|
||||
assert resolved.end_time == 60.0
|
||||
|
||||
def test_zero_asset_duration(self):
|
||||
"""素材时长为 0 → 不裁剪."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=0.0)
|
||||
self.assertTrue(resolved.is_noop)
|
||||
"""素材时长为0 → 不裁剪."""
|
||||
config = TrimConfig(start_time=1.0, duration=5.0)
|
||||
resolved = config.validate_and_resolve(0.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 0.0
|
||||
|
||||
def test_all_three_params_use_start_duration(self):
|
||||
"""三个参数都给了 → 以 start + duration 为准."""
|
||||
cfg = TrimConfig(start_time=5.0, end_time=20.0, duration=8.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
# validate_and_resolve 中 start+end 优先于 start+duration
|
||||
# 因为先检查的是 start>0 and end>0
|
||||
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
|
||||
def test_end_and_duration_with_negative_start(self):
|
||||
"""end + duration推导出来负start → 钳制+重算."""
|
||||
config = TrimConfig(end_time=3.0, duration=10.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 3.0
|
||||
assert resolved.duration == 3.0
|
||||
|
||||
def test_all_three_params_uses_start_duration(self):
|
||||
"""三个都给了,以start+duration为准."""
|
||||
# 实际代码是先判断 start+end(情况1),如果都>0就用
|
||||
# 所以这里测试 start+end 都给了且都>0的情况
|
||||
config = TrimConfig(start_time=2.0, end_time=8.0, duration=10.0)
|
||||
resolved = config.validate_and_resolve(60.0)
|
||||
# 走情况1(start+end都有)
|
||||
assert resolved.start_time == 2.0
|
||||
assert resolved.end_time == 8.0
|
||||
assert resolved.duration == 6.0
|
||||
|
||||
|
||||
class TestTrimEngine(unittest.TestCase):
|
||||
"""TrimEngine 单元测试."""
|
||||
class TestIsValid:
|
||||
"""is_valid 属性测试."""
|
||||
|
||||
def test_build_video_trim_with_start_and_duration(self):
|
||||
"""视频裁剪:start + duration."""
|
||||
trim = TrimConfig(start_time=10.0, duration=5.0)
|
||||
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
|
||||
self.assertIn("trim=start=10.000:duration=5.000", result)
|
||||
self.assertIn("setpts=PTS-STARTPTS", result)
|
||||
self.assertTrue(result.startswith("[0:v]"))
|
||||
self.assertTrue(result.endswith("[v0]"))
|
||||
def test_valid_duration(self):
|
||||
"""时长足够有效."""
|
||||
config = TrimConfig(start_time=0.0, end_time=0.0, duration=5.0)
|
||||
assert config.is_valid is True
|
||||
|
||||
def test_build_video_trim_duration_only(self):
|
||||
"""视频裁剪:只有 duration."""
|
||||
trim = TrimConfig(start_time=0.0, duration=8.0)
|
||||
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
|
||||
self.assertIn("trim=duration=8.000", result)
|
||||
self.assertNotIn("start=", result.split("setpts")[0])
|
||||
def test_zero_duration_invalid(self):
|
||||
"""零时长无效."""
|
||||
config = TrimConfig(duration=0.0)
|
||||
assert config.is_valid is False
|
||||
|
||||
def test_build_audio_trim_with_start(self):
|
||||
"""音频裁剪:start + duration."""
|
||||
trim = TrimConfig(start_time=3.0, duration=7.0)
|
||||
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
|
||||
self.assertIn("atrim=start=3.000:duration=7.000", result)
|
||||
self.assertIn("asetpts=PTS-STARTPTS", result)
|
||||
|
||||
def test_build_audio_trim_noop(self):
|
||||
"""音频裁剪:noop."""
|
||||
trim = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
|
||||
self.assertIn("asetpts=PTS-STARTPTS", result)
|
||||
self.assertNotIn("atrim=", result)
|
||||
|
||||
def test_resolve_segments(self):
|
||||
"""多段裁剪解析."""
|
||||
segments = [
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
|
||||
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=10.0, duration=5.0), order=1),
|
||||
TrimSegment(segment_id="s3", trim=TrimConfig(start_time=20.0, duration=5.0), order=2),
|
||||
]
|
||||
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
|
||||
self.assertEqual(len(resolved), 3)
|
||||
self.assertEqual(resolved[0].segment_id, "s1")
|
||||
self.assertEqual(resolved[0].trim.duration, 5.0)
|
||||
self.assertEqual(resolved[1].segment_id, "s2")
|
||||
self.assertEqual(resolved[1].trim.start_time, 10.0)
|
||||
self.assertEqual(resolved[2].trim.start_time, 20.0)
|
||||
|
||||
def test_resolve_segments_filter_invalid(self):
|
||||
"""多段裁剪:过滤无效段."""
|
||||
segments = [
|
||||
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
|
||||
TrimSegment(segment_id="bad", trim=TrimConfig(start_time=10.0, end_time=5.0), order=1), # end < start
|
||||
]
|
||||
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
|
||||
self.assertEqual(len(resolved), 1)
|
||||
self.assertEqual(resolved[0].segment_id, "good")
|
||||
|
||||
def test_resolve_segments_boundary_clamp(self):
|
||||
"""多段裁剪:边界钳制."""
|
||||
segments = [
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=25.0, duration=10.0), order=0),
|
||||
]
|
||||
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
|
||||
self.assertEqual(len(resolved), 1)
|
||||
self.assertEqual(resolved[0].trim.end_time, 30.0)
|
||||
self.assertAlmostEqual(resolved[0].trim.duration, 5.0, places=3)
|
||||
|
||||
def test_parse_segments_from_list(self):
|
||||
"""从 config 解析多段配置."""
|
||||
config = {
|
||||
"trim_segments": [
|
||||
{"segment_id": "intro", "start_time": 0, "duration": 3, "order": 0},
|
||||
{"segment_id": "highlight", "start_time": 10, "duration": 5, "order": 1},
|
||||
{"segment_id": "outro", "start_time": 50, "duration": 3, "order": 2},
|
||||
]
|
||||
}
|
||||
segments = TrimEngine.parse_segments_from_config(config)
|
||||
self.assertEqual(len(segments), 3)
|
||||
self.assertEqual(segments[0].segment_id, "intro")
|
||||
self.assertEqual(segments[1].trim.start_time, 10.0)
|
||||
self.assertEqual(segments[2].trim.duration, 3.0)
|
||||
|
||||
def test_parse_segments_empty(self):
|
||||
"""无裁剪配置 → 空列表."""
|
||||
self.assertEqual(TrimEngine.parse_segments_from_config(None), [])
|
||||
self.assertEqual(TrimEngine.parse_segments_from_config({}), [])
|
||||
|
||||
def test_parse_single_trim_legacy(self):
|
||||
"""旧格式单段裁剪(trim_start/trim_duration)."""
|
||||
config = {"trim_start": 5.0, "trim_duration": 10.0}
|
||||
segments = TrimEngine.parse_segments_from_config(config)
|
||||
self.assertEqual(len(segments), 1)
|
||||
self.assertEqual(segments[0].trim.start_time, 5.0)
|
||||
self.assertEqual(segments[0].trim.duration, 10.0)
|
||||
def test_min_duration_valid(self):
|
||||
"""刚好等于最小值有效."""
|
||||
config = TrimConfig(duration=MIN_TRIM_DURATION)
|
||||
assert config.is_valid is True
|
||||
|
||||
|
||||
class TestExtractTrimFromClipConfig(unittest.TestCase):
|
||||
"""extract_trim_from_clip_config 单元测试."""
|
||||
class TestIsNoop:
|
||||
"""is_noop 属性测试."""
|
||||
|
||||
def test_trim_subdict(self):
|
||||
"""trim 子字典."""
|
||||
config = {"trim": {"start_time": 5.0, "duration": 10.0}}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.start_time, 5.0)
|
||||
self.assertEqual(result.duration, 10.0)
|
||||
def test_zero_is_noop(self):
|
||||
"""全零是noop."""
|
||||
config = TrimConfig()
|
||||
assert config.is_noop is True
|
||||
|
||||
def test_flat_fields(self):
|
||||
"""扁平字段(trim_start/trim_end/trim_duration)."""
|
||||
config = {"trim_start": 2.0, "trim_end": 8.0}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.start_time, 2.0)
|
||||
self.assertEqual(result.end_time, 8.0)
|
||||
def test_with_duration_not_noop(self):
|
||||
"""有时长不是noop."""
|
||||
config = TrimConfig(duration=10.0)
|
||||
assert config.is_noop is False
|
||||
|
||||
def test_no_trim(self):
|
||||
"""无裁剪配置."""
|
||||
self.assertIsNone(extract_trim_from_clip_config(None))
|
||||
self.assertIsNone(extract_trim_from_clip_config({}))
|
||||
self.assertIsNone(extract_trim_from_clip_config({"other": "value"}))
|
||||
def test_with_start_not_noop(self):
|
||||
"""有start不是noop."""
|
||||
config = TrimConfig(start_time=5.0)
|
||||
assert config.is_noop is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
class TestTrimFromStart:
|
||||
"""trim_from_start 属性测试."""
|
||||
|
||||
def test_zero_start_is_from_start(self):
|
||||
"""start=0是从开头裁."""
|
||||
config = TrimConfig(start_time=0.0)
|
||||
assert config.trim_from_start is True
|
||||
|
||||
def test_positive_start_not_from_start(self):
|
||||
"""有start不是从开头裁."""
|
||||
config = TrimConfig(start_time=5.0)
|
||||
assert config.trim_from_start is False
|
||||
|
||||
|
||||
class TestTrimSegment:
|
||||
"""TrimSegment 测试."""
|
||||
|
||||
def test_from_dict_basic(self):
|
||||
"""基本解析."""
|
||||
seg = TrimSegment.from_dict({
|
||||
"start_time": 5.0,
|
||||
"duration": 10.0,
|
||||
"segment_id": "seg1",
|
||||
}, default_order=0)
|
||||
assert seg.segment_id == "seg1"
|
||||
assert seg.trim.start_time == 5.0
|
||||
assert seg.trim.duration == 10.0
|
||||
assert seg.order == 0
|
||||
|
||||
def test_from_dict_with_order(self):
|
||||
"""带order的解析."""
|
||||
seg = TrimSegment.from_dict({
|
||||
"start_time": 1.0,
|
||||
"end_time": 4.0,
|
||||
"order": 2,
|
||||
})
|
||||
assert seg.order == 2
|
||||
assert seg.trim.start_time == 1.0
|
||||
assert seg.trim.end_time == 4.0
|
||||
|
||||
def test_from_dict_default_segment_id(self):
|
||||
"""缺省segment_id时用默认值."""
|
||||
seg = TrimSegment.from_dict({"duration": 5.0}, default_order=3)
|
||||
assert seg.segment_id == "seg_3"
|
||||
assert seg.order == 3
|
||||
|
||||
def test_from_dict_empty_string_segment_id(self):
|
||||
"""空字符串segment_id走默认."""
|
||||
seg = TrimSegment.from_dict({
|
||||
"segment_id": "",
|
||||
"duration": 5.0,
|
||||
}, default_order=5)
|
||||
assert seg.segment_id == "seg_5"
|
||||
|
||||
Reference in New Issue
Block a user