test(unit): 第64波 - speed + chroma_key + color_grade 引擎配置 (+96) #860

Merged
xiaoxia merged 1 commits from test/wave64-speed-chroma-color-grade-config into develop 2026-07-25 08:13:00 +08:00
3 changed files with 559 additions and 652 deletions
+189
View File
@@ -0,0 +1,189 @@
"""绿幕抠像引擎单元测试 - 配置解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.chroma_key_engine import (
CHROMA_KEY_PRESETS,
ChromaKeyConfig,
)
class TestChromaKeyConfigDefaults:
"""默认配置测试."""
def test_default_values(self):
"""默认值正确."""
config = ChromaKeyConfig()
assert config.enabled is False
assert config.key_color == "#00FF00"
assert config.similarity == 0.3
assert config.blend == 0.1
assert config.spill_suppress == 0.0
class TestChromaKeyConfigFromDict:
"""from_dict 配置解析测试."""
def test_none_returns_disabled(self):
"""None 返回禁用配置."""
config = ChromaKeyConfig.from_dict(None)
assert config.enabled is False
def test_empty_dict_returns_disabled(self):
"""空字典返回禁用."""
config = ChromaKeyConfig.from_dict({})
assert config.enabled is False
def test_disabled_returns_disabled(self):
"""enabled=False 返回禁用."""
config = ChromaKeyConfig.from_dict({"enabled": False})
assert config.enabled is False
def test_enabled_default_values(self):
"""启用时使用默认参数."""
config = ChromaKeyConfig.from_dict({"enabled": True})
assert config.enabled is True
assert config.key_color == "#00FF00"
assert config.similarity == 0.3
assert config.blend == 0.1
assert config.spill_suppress == 0.0
def test_custom_key_color(self):
"""自定义抠像颜色."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"key_color": "#0000FF",
})
assert config.key_color == "#0000FF"
def test_similarity_parsed(self):
"""相似度解析."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": 0.5,
})
assert config.similarity == 0.5
def test_similarity_clamped_min(self):
"""相似度下限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": 0.001,
})
assert config.similarity == 0.01
def test_similarity_clamped_max(self):
"""相似度上限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": 2.0,
})
assert config.similarity == 1.0
def test_blend_clamped_min(self):
"""混合度下限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"blend": -0.5,
})
assert config.blend == 0.0
def test_blend_clamped_max(self):
"""混合度上限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"blend": 1.5,
})
assert config.blend == 1.0
def test_spill_suppress_clamped(self):
"""溢色抑制钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"spill_suppress": 2.0,
})
assert config.spill_suppress == 1.0
def test_invalid_similarity_falls_back(self):
"""无效相似度回退到默认."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": "not_a_number",
})
assert config.similarity == 0.3
def test_invalid_blend_falls_back(self):
"""无效混合度回退."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"blend": "high",
})
assert config.blend == 0.1
def test_key_color_stripped(self):
"""颜色值去除首尾空格."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"key_color": " #FF0000 ",
})
assert config.key_color == "#FF0000"
def test_all_params_custom(self):
"""所有参数自定义."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"key_color": "#0000FF",
"similarity": 0.45,
"blend": 0.15,
"spill_suppress": 0.6,
})
assert config.enabled is True
assert config.key_color == "#0000FF"
assert config.similarity == 0.45
assert config.blend == 0.15
assert config.spill_suppress == 0.6
class TestHasEffect:
"""has_effect 方法测试."""
def test_disabled_no_effect(self):
"""禁用时无效果."""
config = ChromaKeyConfig(enabled=False)
assert config.has_effect() is False
def test_enabled_with_similarity_has_effect(self):
"""启用且有相似度时有效果."""
config = ChromaKeyConfig(enabled=True, similarity=0.3)
assert config.has_effect() is True
def test_zero_similarity_no_effect(self):
"""相似度为0时无效果."""
config = ChromaKeyConfig(enabled=True, similarity=0.0)
assert config.has_effect() is False
class TestChromaKeyPresets:
"""预设配置测试."""
def test_five_presets(self):
"""5个预设."""
assert len(CHROMA_KEY_PRESETS) == 5
def test_preset_names(self):
"""预设名称正确."""
assert "green_screen" in CHROMA_KEY_PRESETS
assert "blue_screen" in CHROMA_KEY_PRESETS
assert "red_screen" in CHROMA_KEY_PRESETS
assert "precise_green" in CHROMA_KEY_PRESETS
assert "soft_green" in CHROMA_KEY_PRESETS
def test_presets_have_required_keys(self):
"""每个预设包含必要字段."""
for name, preset in CHROMA_KEY_PRESETS.items():
assert "key_color" in preset, f"{name} missing key_color"
assert "similarity" in preset, f"{name} missing similarity"
assert "blend" in preset, f"{name} missing blend"
assert "spill_suppress" in preset, f"{name} missing spill_suppress"
+179 -499
View File
@@ -1,97 +1,25 @@
"""滤镜调色引擎单元测试."""
"""调色引擎单元测试 - 配置解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.color_grade_engine import (
DEFAULT_PARAMS,
PARAM_RANGES,
PRESET_BW,
PRESET_CINEMA,
PRESET_COOL,
PRESET_DISPLAY_NAMES,
PRESET_FILM,
PRESET_FRESH,
PRESET_JAPANESE,
PRESET_PARAMS,
PRESET_VINTAGE,
PRESET_WARM,
VALID_PRESETS,
ColorGradeConfig,
ColorGradeEngine,
get_preset_names,
get_preset_params,
)
# ── 预设常量测试 ──────────────────────────────────────────────────────────────
class TestColorGradeConfigDefaults:
"""默认配置测试."""
class TestPresetConstants:
"""预设常量完整性测试."""
def test_eight_presets_defined(self):
"""应该有8种预设."""
assert len(PRESET_PARAMS) == 8
assert len(PRESET_DISPLAY_NAMES) == 8
def test_all_presets_have_display_names(self):
"""每个预设都应该有中文显示名."""
for key in PRESET_PARAMS:
assert key in PRESET_DISPLAY_NAMES
assert PRESET_DISPLAY_NAMES[key] # 非空
def test_preset_params_have_all_keys(self):
"""每个预设应该包含所有5个参数."""
required_keys = {"brightness", "contrast", "saturation", "temperature", "hue"}
for key, params in PRESET_PARAMS.items():
assert required_keys.issubset(params.keys()), f"预设 {key} 缺少参数"
def test_preset_params_in_valid_range(self):
"""所有预设参数应该在合法范围内."""
for preset_name, params in PRESET_PARAMS.items():
for param_name, value in params.items():
min_val, max_val = PARAM_RANGES[param_name]
assert (
min_val <= value <= max_val
), f"预设 {preset_name}{param_name}={value} 超出范围 [{min_val}, {max_val}]"
def test_black_white_has_zero_saturation(self):
"""黑白预设饱和度应该为0."""
assert PRESET_PARAMS[PRESET_BW]["saturation"] == 0
def test_warm_preset_has_positive_temperature(self):
"""暖色预设色温应该为正."""
assert PRESET_PARAMS[PRESET_WARM]["temperature"] > 0
def test_cool_preset_has_negative_temperature(self):
"""冷色预设色温应该为负."""
assert PRESET_PARAMS[PRESET_COOL]["temperature"] < 0
# ── ColorGradeConfig.from_dict 测试 ───────────────────────────────────────────
class TestColorGradeConfigFromDict:
"""配置字典解析测试."""
def test_none_config(self):
"""None返回disabled."""
config = ColorGradeConfig.from_dict(None)
assert not config.enabled
def test_empty_dict(self):
"""空字典返回disabled."""
config = ColorGradeConfig.from_dict({})
assert not config.enabled
def test_enabled_false(self):
"""enabled=False返回disabled."""
config = ColorGradeConfig.from_dict({"enabled": False})
assert not config.enabled
def test_enabled_only(self):
"""只开enabled,无预设无自定义参数."""
config = ColorGradeConfig.from_dict({"enabled": True})
assert config.enabled
def test_default_values(self):
"""默认值正确."""
config = ColorGradeConfig()
assert config.enabled is False
assert config.preset == ""
assert config.brightness is None
assert config.contrast is None
@@ -99,474 +27,226 @@ class TestColorGradeConfigFromDict:
assert config.temperature is None
assert config.hue is None
def test_with_preset(self):
"""指定预设."""
config = ColorGradeConfig.from_dict({"enabled": True, "preset": PRESET_FRESH})
assert config.enabled
assert config.preset == PRESET_FRESH
def test_invalid_preset_ignored(self):
"""无效预设名应该被忽略."""
config = ColorGradeConfig.from_dict({"enabled": True, "preset": "invalid_preset"})
assert config.preset == "" # 被清空
class TestColorGradeConfigFromDict:
"""from_dict 配置解析测试."""
def test_with_custom_params(self):
"""自定义参数覆盖."""
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": 20,
"contrast": -10,
"saturation": 150,
"temperature": 25,
"hue": 30,
}
)
assert config.enabled
assert config.brightness == 20
assert config.contrast == -10
assert config.saturation == 150
assert config.temperature == 25
assert config.hue == 30
def test_none_returns_disabled(self):
"""None 返回禁用配置."""
config = ColorGradeConfig.from_dict(None)
assert config.enabled is False
def test_string_numeric_values(self):
"""字符串形式的数字应该能解析."""
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": "20.5",
"saturation": "150",
}
)
assert config.brightness == 20.5
assert config.saturation == 150.0
def test_empty_dict_returns_disabled(self):
"""空字典返回禁用."""
config = ColorGradeConfig.from_dict({})
assert config.enabled is False
def test_invalid_value_returns_none(self):
"""无效值应该返回None(不覆盖)."""
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": "not_a_number",
}
)
def test_disabled_returns_disabled(self):
"""enabled=False 返回禁用."""
config = ColorGradeConfig.from_dict({"enabled": False})
assert config.enabled is False
def test_enabled_no_params(self):
"""启用但无自定义参数."""
config = ColorGradeConfig.from_dict({"enabled": True})
assert config.enabled is True
assert config.preset == ""
assert config.brightness is None
def test_with_preset(self):
"""指定预设."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"preset": "fresh",
})
assert config.enabled is True
assert config.preset == "fresh"
# ── ColorGradeConfig.resolve_params 测试 ──────────────────────────────────────
def test_invalid_preset_ignored(self):
"""无效预设被忽略."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"preset": "unknown_preset",
})
assert config.preset == ""
def test_custom_brightness(self):
"""自定义亮度."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"brightness": 20,
})
assert config.brightness == 20.0
def test_custom_all_params(self):
"""所有参数自定义."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"brightness": 10,
"contrast": 15,
"saturation": 120,
"temperature": -5,
"hue": 10,
})
assert config.brightness == 10.0
assert config.contrast == 15.0
assert config.saturation == 120.0
assert config.temperature == -5.0
assert config.hue == 10.0
def test_invalid_param_value_returns_none(self):
"""无效参数值返回None(不覆盖)."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"brightness": "not_a_number",
})
assert config.brightness is None
def test_null_param_returns_none(self):
"""null参数值返回None."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"contrast": None,
})
assert config.contrast is None
def test_preset_with_custom_override(self):
"""预设 + 自定义覆盖."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"preset": "vintage",
"brightness": 5,
})
assert config.preset == "vintage"
assert config.brightness == 5.0
class TestResolveParams:
"""参数解析与边界钳制测试."""
"""resolve_params 参数解析测试."""
def test_default_params_when_empty(self):
"""无预设无自定义时返回默认."""
config = ColorGradeConfig(enabled=True)
def test_disabled_returns_defaults(self):
"""禁用配置也返回默认参数."""
config = ColorGradeConfig(enabled=False)
params = config.resolve_params()
for key, val in DEFAULT_PARAMS.items():
assert params[key] == val
def test_preset_params_applied(self):
"""预设参数应该被应用."""
config = ColorGradeConfig(enabled=True, preset=PRESET_FRESH)
def test_no_preset_no_custom_returns_defaults(self):
"""预设无自定义返回默认值."""
config = ColorGradeConfig(enabled=True)
params = config.resolve_params()
preset = PRESET_PARAMS[PRESET_FRESH]
for key, val in preset.items():
assert params[key] == val
for key, val in DEFAULT_PARAMS.items():
assert abs(params[key] - val) < 0.001
def test_preset_applies_params(self):
"""预设应用参数."""
config = ColorGradeConfig(enabled=True, preset="fresh")
params = config.resolve_params()
# 清新预设亮度=8
assert params["brightness"] == 8
assert params["saturation"] == 120
def test_custom_overrides_preset(self):
"""自定义参数应该覆盖预设."""
"""自定义参数覆盖预设."""
config = ColorGradeConfig(
enabled=True,
preset=PRESET_FRESH,
preset="fresh",
brightness=50, # 覆盖预设的8
)
params = config.resolve_params()
assert params["brightness"] == 50
# 其他参数还是预设值
assert params["contrast"] == PRESET_PARAMS[PRESET_FRESH]["contrast"]
# 其他参数仍用预设值
assert params["saturation"] == 120
def test_clamp_brightness_high(self):
"""亮度超过上限应该被钳制."""
def test_brightness_clamped(self):
"""亮度边界钳制."""
config = ColorGradeConfig(enabled=True, brightness=200)
params = config.resolve_params()
assert params["brightness"] == 100
assert params["brightness"] == 100.0
def test_clamp_brightness_low(self):
"""亮度低于下限应该被钳制."""
config = ColorGradeConfig(enabled=True, brightness=-200)
def test_saturation_clamped_low(self):
"""饱和度下限钳制."""
config = ColorGradeConfig(enabled=True, saturation=-10)
params = config.resolve_params()
assert params["brightness"] == -100
assert params["saturation"] == 0.0
def test_clamp_saturation_low(self):
"""饱和度低于0应该被钳制到0."""
config = ColorGradeConfig(enabled=True, saturation=-50)
params = config.resolve_params()
assert params["saturation"] == 0
def test_clamp_saturation_high(self):
"""饱和度超过200应该被钳制."""
def test_saturation_clamped_high(self):
"""饱和度上限钳制."""
config = ColorGradeConfig(enabled=True, saturation=300)
params = config.resolve_params()
assert params["saturation"] == 200
assert params["saturation"] == 200.0
def test_clamp_hue_high(self):
"""色调超过180应该被钳制."""
config = ColorGradeConfig(enabled=True, hue=270)
def test_hue_clamped(self):
"""色调边界钳制."""
config = ColorGradeConfig(enabled=True, hue=200)
params = config.resolve_params()
assert params["hue"] == 180
assert params["hue"] == 180.0
def test_clamp_hue_low(self):
"""色调低于-180应该被钳制."""
config = ColorGradeConfig(enabled=True, hue=-270)
def test_hue_negative_clamped(self):
"""色调边界钳制."""
config = ColorGradeConfig(enabled=True, hue=-200)
params = config.resolve_params()
assert params["hue"] == -180
assert params["hue"] == -180.0
def test_clamp_contrast(self):
"""对比度越界应该被钳制."""
config = ColorGradeConfig(enabled=True, contrast=150)
def test_returns_all_five_params(self):
"""返回所有5个参数."""
config = ColorGradeConfig(enabled=True)
params = config.resolve_params()
assert params["contrast"] == 100
config2 = ColorGradeConfig(enabled=True, contrast=-150)
params2 = config2.resolve_params()
assert params2["contrast"] == -100
def test_clamp_temperature(self):
"""色温越界应该被钳制."""
config = ColorGradeConfig(enabled=True, temperature=150)
params = config.resolve_params()
assert params["temperature"] == 100
def test_preset_with_clamping(self):
"""预设+自定义覆盖,自定义值超范围仍需钳制."""
config = ColorGradeConfig(
enabled=True,
preset=PRESET_FRESH,
brightness=999, # 超范围
)
params = config.resolve_params()
assert params["brightness"] == 100 # 被钳制
# ── ColorGradeConfig.has_effect 测试 ──────────────────────────────────────────
assert set(params.keys()) == {
"brightness", "contrast", "saturation", "temperature", "hue"
}
class TestHasEffect:
"""是否有实际效果判断测试."""
"""has_effect 方法测试."""
def test_disabled_has_no_effect(self):
"""disabled的配置has_effect应该返回False."""
config = ColorGradeConfig(enabled=False)
assert not config.has_effect()
def test_default_params_no_effect(self):
"""所有参数都是默认值时应该返回False."""
def test_default_no_effect(self):
"""默认配置无效果."""
config = ColorGradeConfig(enabled=True)
assert not config.has_effect()
assert config.has_effect() is False
def test_brightness_change_has_effect(self):
"""亮度变化应该有效果."""
def test_with_preset_has_effect(self):
"""有预设时有效果."""
config = ColorGradeConfig(enabled=True, preset="cinema")
assert config.has_effect() is True
def test_custom_brightness_has_effect(self):
"""自定义亮度有效果."""
config = ColorGradeConfig(enabled=True, brightness=10)
assert config.has_effect()
assert config.has_effect() is True
def test_saturation_100_no_effect(self):
"""饱和度100是默认值,无效果."""
config = ColorGradeConfig(enabled=True, saturation=100)
assert not config.has_effect()
def test_disabled_still_checks_params(self):
"""禁用也根据参数判断(结果仍可能有效果但不启用)."""
# has_effect 只看参数,不看 enabled
config = ColorGradeConfig(enabled=False, preset="warm")
assert config.has_effect() is True
def test_saturation_not_100_has_effect(self):
"""饱和度不等于100有效果."""
config = ColorGradeConfig(enabled=True, saturation=99)
assert config.has_effect()
def test_preset_has_effect(self):
"""预设通常有效果."""
for preset in PRESET_PARAMS:
config = ColorGradeConfig(enabled=True, preset=preset)
assert config.has_effect(), f"预设 {preset} 应该有效果"
def test_custom_zero_override_no_effect(self):
"""用预设但所有自定义值都设为默认值抵消 → 应该has_effect看实际值."""
# 黑白预设饱和度=0,如果手动覆盖饱和度=100、其他都=默认值,则可能无效果
config = ColorGradeConfig(
enabled=True,
preset=PRESET_BW,
brightness=0,
contrast=0,
saturation=100,
temperature=0,
hue=0,
)
assert not config.has_effect()
def test_black_white_preset_has_effect(self):
"""黑白预设(饱和度=0有效果."""
config = ColorGradeConfig(enabled=True, preset="black_white")
assert config.has_effect() is True
# ── ColorGradeEngine 参数映射测试 ─────────────────────────────────────────────
class TestPresets:
"""预设常量测试."""
def test_eight_valid_presets(self):
"""8个有效预设."""
assert len(VALID_PRESETS) == 8
class TestParameterMapping:
"""FFmpeg参数映射测试."""
def test_preset_params_match_valid(self):
"""所有预设都在有效列表中."""
for name in PRESET_PARAMS:
assert name in VALID_PRESETS
def test_brightness_mapping_zero(self):
"""亮度0 → 0.0."""
assert ColorGradeEngine._map_brightness(0) == 0.0
def test_each_preset_has_all_params(self):
"""每个预设包含所有5个参数."""
for name, params in PRESET_PARAMS.items():
for key in ["brightness", "contrast", "saturation", "temperature", "hue"]:
assert key in params, f"{name} missing {key}"
def test_brightness_mapping_max(self):
"""亮度100 → 1.0."""
assert ColorGradeEngine._map_brightness(100) == 1.0
def test_brightness_mapping_min(self):
"""亮度-100 → -1.0."""
assert ColorGradeEngine._map_brightness(-100) == -1.0
def test_contrast_mapping_zero(self):
"""对比度0 → 1.0(原始)."""
assert ColorGradeEngine._map_contrast(0) == 1.0
def test_contrast_mapping_positive(self):
"""正对比度应该 > 1.0."""
assert ColorGradeEngine._map_contrast(50) == 1.5
assert ColorGradeEngine._map_contrast(100) == 2.0
def test_contrast_mapping_negative(self):
"""负对比度应该 < 1.0."""
assert ColorGradeEngine._map_contrast(-50) == 0.5
assert ColorGradeEngine._map_contrast(-100) == 0.0
def test_saturation_mapping_default(self):
"""饱和度100 → 1.0."""
assert ColorGradeEngine._map_saturation(100) == 1.0
def test_saturation_mapping_zero(self):
"""饱和度0 → 0.0(黑白)."""
assert ColorGradeEngine._map_saturation(0) == 0.0
def test_saturation_mapping_double(self):
"""饱和度200 → 2.0."""
assert ColorGradeEngine._map_saturation(200) == 2.0
def test_temperature_warm(self):
"""暖色温应该红+蓝-."""
red, green, blue = ColorGradeEngine._map_temperature(100)
assert red > 0
assert blue < 0
def test_temperature_cool(self):
"""冷色温应该红-蓝+."""
red, green, blue = ColorGradeEngine._map_temperature(-100)
assert red < 0
assert blue > 0
def test_temperature_zero(self):
"""色温0应该全0."""
red, green, blue = ColorGradeEngine._map_temperature(0)
assert red == 0
assert green == 0
assert blue == 0
def test_hue_mapping_passthrough(self):
"""色调直接透传."""
assert ColorGradeEngine._map_hue(0) == 0
assert ColorGradeEngine._map_hue(90) == 90
assert ColorGradeEngine._map_hue(-45) == -45
# ── ColorGradeEngine.build_filter 测试 ────────────────────────────────────────
class TestBuildFilter:
"""滤镜字符串构建测试."""
def test_disabled_returns_empty(self):
"""disabled配置返回空."""
config = ColorGradeConfig(enabled=False)
result = ColorGradeEngine.build_filter(config)
assert result == ""
def test_no_effect_returns_empty(self):
"""无效果的配置返回空."""
config = ColorGradeConfig(enabled=True)
result = ColorGradeEngine.build_filter(config)
assert result == ""
def test_brightness_only(self):
"""只有亮度调整."""
config = ColorGradeConfig(enabled=True, brightness=20)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
assert "brightness=" in result
assert "contrast=" not in result
assert "saturation=" not in result
def test_contrast_only(self):
"""只有对比度调整."""
config = ColorGradeConfig(enabled=True, contrast=30)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
assert "contrast=" in result
def test_saturation_only(self):
"""只有饱和度调整."""
config = ColorGradeConfig(enabled=True, saturation=50)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
assert "saturation=" in result
def test_temperature_only(self):
"""只有色温调整."""
config = ColorGradeConfig(enabled=True, temperature=20)
result = ColorGradeEngine.build_filter(config)
assert "colorbalance=" in result
# 暖色调应该有红通道调整
assert "rs=" in result
def test_hue_only(self):
"""只有色调调整."""
config = ColorGradeConfig(enabled=True, hue=30)
result = ColorGradeEngine.build_filter(config)
assert "hue=h=" in result
def test_with_input_output_labels(self):
"""带输入输出标签."""
config = ColorGradeConfig(enabled=True, brightness=10)
result = ColorGradeEngine.build_filter(config, input_label="[0:v]", output_label="[out]")
assert result.startswith("[0:v]")
assert result.endswith("[out]")
def test_preset_fresh_filter(self):
"""清新预设应该生成eq滤镜."""
config = ColorGradeConfig(enabled=True, preset=PRESET_FRESH)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
# 清新预设饱和度>100,应该有saturation
assert "saturation=" in result
def test_preset_bw_filter(self):
"""黑白预设应该有saturation=0."""
config = ColorGradeConfig(enabled=True, preset=PRESET_BW)
result = ColorGradeEngine.build_filter(config)
assert "saturation=0.0" in result
def test_combined_params(self):
"""多个参数组合."""
config = ColorGradeConfig(
enabled=True,
brightness=15,
contrast=20,
saturation=130,
temperature=10,
hue=5,
)
result = ColorGradeEngine.build_filter(config)
# 应该有三个滤镜用逗号连接
assert "eq=" in result
assert "colorbalance=" in result
assert "hue=" in result
# 逗号分隔
assert "," in result
def test_filter_chain_order(self):
"""滤镜顺序应该是 eq → colorbalance → hue."""
config = ColorGradeConfig(
enabled=True,
brightness=10,
temperature=10,
hue=10,
)
result = ColorGradeEngine.build_filter(config)
eq_pos = result.find("eq=")
cb_pos = result.find("colorbalance=")
hue_pos = result.find("hue=")
assert eq_pos < cb_pos < hue_pos
def test_zero_temperature_no_colorbalance(self):
"""色温为0不应该有colorbalance滤镜."""
config = ColorGradeConfig(enabled=True, temperature=0, brightness=10)
result = ColorGradeEngine.build_filter(config)
assert "colorbalance" not in result
def test_zero_hue_no_hue_filter(self):
"""色调为0不应该有hue滤镜."""
config = ColorGradeConfig(enabled=True, hue=0, brightness=10)
result = ColorGradeEngine.build_filter(config)
assert "hue=" not in result
def test_all_presets_generate_valid_filter(self):
"""所有预设都应该能生成有效的非空滤镜."""
for preset_name in PRESET_PARAMS:
config = ColorGradeConfig(enabled=True, preset=preset_name)
result = ColorGradeEngine.build_filter(config)
assert result, f"预设 {preset_name} 应该生成非空滤镜"
# 不应该有语法错误(连续冒号、空参数等)
assert "::" not in result
assert result[0] != ":"
assert result[-1] != ":"
# ── 便捷函数测试 ──────────────────────────────────────────────────────────────
class TestHelperFunctions:
"""便捷函数测试."""
def test_get_preset_names_returns_eight(self):
"""应该返回8个预设."""
names = get_preset_names()
assert len(names) == 8
# 每个是 (key, display_name) 元组
for key, display in names:
assert key in PRESET_PARAMS
assert isinstance(display, str)
assert display
def test_get_preset_params_valid(self):
"""获取有效预设的参数."""
params = get_preset_params(PRESET_FRESH)
assert params is not None
assert params == PRESET_PARAMS[PRESET_FRESH]
def test_get_preset_params_invalid(self):
"""获取无效预设返回None."""
params = get_preset_params("nonexistent")
assert params is None
# ── 分段调色(不同clip不同滤镜)概念验证 ──────────────────────────────────────
class TestPerClipGrading:
"""分段调色概念验证 — 不同配置生成不同滤镜."""
def test_different_presets_different_filters(self):
"""不同预设应该生成不同的滤镜字符串."""
configs = [
ColorGradeConfig(enabled=True, preset=PRESET_FRESH),
ColorGradeConfig(enabled=True, preset=PRESET_VINTAGE),
ColorGradeConfig(enabled=True, preset=PRESET_BW),
]
filters = [ColorGradeEngine.build_filter(c) for c in configs]
# 三个滤镜应该各不相同
assert len(set(filters)) == 3
def test_same_preset_same_filter(self):
"""相同配置应该生成相同滤镜(确定性)."""
config1 = ColorGradeConfig(enabled=True, preset=PRESET_CINEMA)
config2 = ColorGradeConfig(enabled=True, preset=PRESET_CINEMA)
assert ColorGradeEngine.build_filter(config1) == ColorGradeEngine.build_filter(config2)
def test_custom_override_changes_filter(self):
"""自定义覆盖应该改变滤镜."""
base = ColorGradeConfig(enabled=True, preset=PRESET_FILM)
modified = ColorGradeConfig(enabled=True, preset=PRESET_FILM, brightness=50)
assert ColorGradeEngine.build_filter(base) != ColorGradeEngine.build_filter(modified)
def test_clips_with_and_without_grading(self):
"""有的clip有调色有的没有,生成结果不同."""
with_grade = ColorGradeConfig(enabled=True, preset=PRESET_WARM)
without_grade = ColorGradeConfig(enabled=False)
filter_with = ColorGradeEngine.build_filter(with_grade, "[0:v]", "[v0]")
filter_without = ColorGradeEngine.build_filter(without_grade, "[0:v]", "[v0]")
assert filter_with # 有调色应该非空
# 无调色但带标签时应该走 copy 直通(保证标签传递)
assert "[0:v]copy[v0]" in filter_without
def test_param_ranges_defined(self):
"""参数范围定义完整."""
assert set(PARAM_RANGES.keys()) == {
"brightness", "contrast", "saturation", "temperature", "hue"
}
+191 -153
View File
@@ -1,6 +1,9 @@
"""视频调速引擎单元测试."""
"""视频调速引擎单元测试 - 配置解析 + 滤镜生成等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.speed_engine import (
MAX_SPEED,
MIN_SPEED,
@@ -8,262 +11,297 @@ from video_processing.speed_engine import (
SpeedEngine,
)
# ─── SpeedConfig 解析与校验 ──────────────────────────────────
# ── 常量测试 ──────────────────────────────────────────────────
class TestSpeedConfig:
class TestConstants:
"""常量值测试."""
def test_speed_ranges(self):
"""速度范围合理."""
assert MIN_SPEED == 0.25
assert MAX_SPEED == 4.0
assert MIN_SPEED < MAX_SPEED
# ── SpeedConfig 测试 ────────────────────────────────────────
class TestSpeedConfigDefaults:
"""默认配置测试."""
def test_default_values(self):
"""默认值正确."""
config = SpeedConfig()
assert config.speed == 1.0
assert config.pitch_correct is True
def test_parse_none(self):
def test_is_original_default(self):
"""默认配置是原速."""
config = SpeedConfig()
assert config.is_original is True
class TestSpeedConfigParse:
"""parse 配置解析测试."""
def test_none_returns_default(self):
"""None 返回默认配置."""
config = SpeedConfig.parse(None)
assert config.speed == 1.0
assert config.pitch_correct is True
def test_parse_empty_dict(self):
def test_empty_dict_returns_default(self):
"""空 dict 返回默认配置."""
config = SpeedConfig.parse({})
assert config.speed == 1.0
assert config.is_original is True
def test_parse_valid_speed(self):
def test_custom_speed(self):
"""自定义速度."""
config = SpeedConfig.parse({"speed": 2.0})
assert config.speed == 2.0
assert config.is_original is False
def test_parse_pitch_correct_false(self):
config = SpeedConfig.parse({"pitch_correct": False})
def test_pitch_correct_disabled(self):
"""禁用音调修正."""
config = SpeedConfig.parse({"speed": 1.5, "pitch_correct": False})
assert config.pitch_correct is False
def test_parse_invalid_speed_type(self):
def test_invalid_speed_type_falls_back(self):
"""无效速度类型回退到默认."""
config = SpeedConfig.parse({"speed": "fast"})
assert config.speed == 1.0
def test_parse_invalid_pitch_type(self):
config = SpeedConfig.parse({"pitch_correct": "yes"})
def test_invalid_pitch_correct_type_falls_back(self):
"""无效pitch_correct类型回退到默认."""
config = SpeedConfig.parse({"speed": 2.0, "pitch_correct": "yes"})
assert config.pitch_correct is True
def test_clamp_below_min(self):
def test_non_dict_input_returns_default(self):
"""非dict输入返回默认."""
config = SpeedConfig.parse("not_a_dict")
assert config.is_original is True
class TestSpeedConfigClamp:
"""clamp 边界钳制测试."""
def test_speed_below_min_clamped(self):
"""低于最小值钳制."""
config = SpeedConfig(speed=0.1)
config.clamp()
assert config.speed == MIN_SPEED
def test_clamp_zero(self):
config = SpeedConfig(speed=0)
config.clamp()
assert config.speed == 1.0
def test_clamp_negative(self):
config = SpeedConfig(speed=-1.0)
config.clamp()
assert config.speed == 1.0
def test_clamp_above_max(self):
def test_speed_above_max_clamped(self):
"""高于最大值钳制."""
config = SpeedConfig(speed=10.0)
config.clamp()
assert config.speed == MAX_SPEED
def test_clamp_within_range(self):
def test_zero_speed_falls_back_to_default(self):
"""速度为0回退到默认."""
config = SpeedConfig(speed=0)
config.clamp()
assert config.speed == 1.0
def test_negative_speed_falls_back_to_default(self):
"""负速度回退到默认."""
config = SpeedConfig(speed=-2.0)
config.clamp()
assert config.speed == 1.0
def test_speed_at_min_ok(self):
"""最小值边界."""
config = SpeedConfig(speed=MIN_SPEED)
config.clamp()
assert config.speed == MIN_SPEED
def test_speed_at_max_ok(self):
"""最大值边界."""
config = SpeedConfig(speed=MAX_SPEED)
config.clamp()
assert config.speed == MAX_SPEED
def test_speed_in_range_unchanged(self):
"""合法范围内不修改."""
config = SpeedConfig(speed=1.5)
config.clamp()
assert config.speed == 1.5
def test_is_original_true(self):
config = SpeedConfig(speed=1.0)
assert config.is_original is True
def test_is_original_false(self):
config = SpeedConfig(speed=1.5)
assert config.is_original is False
def test_parse_clamps_automatically(self):
"""parse 方法应该自动调用 clamp."""
def test_parse_auto_clamps(self):
"""parse 自动钳制."""
config = SpeedConfig.parse({"speed": 100.0})
assert config.speed == MAX_SPEED
# ─── SpeedEngine 视频滤镜 ────────────────────────────────────
class TestIsOriginal:
"""is_original 属性测试."""
def test_exactly_one(self):
"""速度恰好为1."""
assert SpeedConfig(speed=1.0).is_original is True
def test_very_close_to_one(self):
"""非常接近1也算原速."""
assert SpeedConfig(speed=1.0000001).is_original is True
def test_not_one(self):
"""不是1."""
assert SpeedConfig(speed=1.1).is_original is False
assert SpeedConfig(speed=0.9).is_original is False
class TestSpeedEngineVideoFilter:
# ── SpeedEngine 测试 ────────────────────────────────────────
class TestBuildVideoFilter:
"""build_video_filter 测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_returns_empty(self):
def test_original_speed_empty_filter(self):
"""原速返回空字符串(跳过滤镜)."""
config = SpeedConfig(speed=1.0)
assert self.engine.build_video_filter(config) == ""
def test_double_speed(self):
def test_speed_up_2x(self):
"""2倍速."""
config = SpeedConfig(speed=2.0)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/2.0" in result
assert "setpts=PTS/2.0000" == result
def test_half_speed(self):
def test_slow_down_half(self):
"""0.5倍速."""
config = SpeedConfig(speed=0.5)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/0.5" in result
assert "setpts=PTS/0.5000" == result
def test_quarter_speed(self):
config = SpeedConfig(speed=0.25)
def test_contains_setpts(self):
"""包含setpts滤镜."""
config = SpeedConfig(speed=1.5)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/0.25" in result
def test_quad_speed(self):
config = SpeedConfig(speed=4.0)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/4.0" in result
assert "setpts=PTS/" in result
# ─── SpeedEngine 音频滤镜(atempo 多级串联) ─────────────────
class TestBuildAudioFilter:
"""build_audio_filter 测试."""
class TestSpeedEngineAudioFilter:
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_returns_empty(self):
def test_original_speed_empty_filter(self):
"""原速返回空字符串."""
config = SpeedConfig(speed=1.0)
assert self.engine.build_audio_filter(config) == ""
def test_double_speed_single_stage(self):
"""2x 在 atempo 单级范围内,只需一个 atempo."""
def test_single_stage_2x(self):
"""2倍速单级atempo."""
config = SpeedConfig(speed=2.0)
result = self.engine.build_audio_filter(config)
assert result == "atempo=2.0000"
def test_half_speed_single_stage(self):
def test_single_stage_half(self):
"""0.5倍速单级atempo."""
config = SpeedConfig(speed=0.5)
result = self.engine.build_audio_filter(config)
assert result == "atempo=0.5000"
def test_quad_speed_two_stages(self):
"""4x 需要两级 atempo: 2.0 * 2.0."""
def test_multi_stage_4x(self):
"""4倍速需要两级 atempo=2.0,atempo=2.0."""
config = SpeedConfig(speed=4.0)
result = self.engine.build_audio_filter(config)
assert result == "atempo=2.0000,atempo=2.0000"
def test_quarter_speed_two_stages(self):
"""0.25x 需要两级 atempo: 0.5 * 0.5."""
def test_multi_stage_quarter(self):
"""0.25倍速需要两级 atempo=0.5,atempo=0.5."""
config = SpeedConfig(speed=0.25)
result = self.engine.build_audio_filter(config)
assert result == "atempo=0.5000,atempo=0.5000"
def test_triple_speed_two_stages(self):
"""3x: 2.0 * 1.5."""
def test_multi_stage_3x(self):
"""3倍速: 2.0 * 1.5."""
config = SpeedConfig(speed=3.0)
result = self.engine.build_audio_filter(config)
parts = result.split(",")
assert len(parts) == 2
assert "atempo=2.0000" in parts
assert "atempo=1.5000" in parts
stages = result.split(",")
assert len(stages) == 2
# 验证两级相乘等于3
values = [float(s.split("=")[1]) for s in stages]
assert abs(values[0] * values[1] - 3.0) < 0.01
def test_03_speed_two_stages(self):
"""0.3x: 0.5 * 0.6."""
config = SpeedConfig(speed=0.3)
result = self.engine.build_audio_filter(config)
parts = result.split(",")
assert len(parts) == 2
assert "atempo=0.5000" in parts
assert "atempo=0.6000" in parts
def test_split_atempo_inside_range(self):
"""0.5~2.0 范围内只返回一级."""
class TestSplitAtempoStages:
"""_split_atempo_stages 测试."""
def test_single_stage_within_range(self):
"""范围内单级."""
stages = SpeedEngine._split_atempo_stages(1.5)
assert len(stages) == 1
assert stages[0] == 1.5
def test_split_atempo_boundary_min(self):
stages = SpeedEngine._split_atempo_stages(0.5)
assert len(stages) == 1
assert stages[0] == 0.5
def test_split_atempo_boundary_max(self):
def test_single_stage_at_max(self):
"""最大值边界单级."""
stages = SpeedEngine._split_atempo_stages(2.0)
assert len(stages) == 1
assert stages[0] == 2.0
def test_split_atempo_product_equals_speed(self):
"""所有级联的乘积应该等于原速度."""
test_cases = [0.25, 0.3, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]
for speed in test_cases:
stages = SpeedEngine._split_atempo_stages(speed)
product = 1.0
for s in stages:
product *= s
assert abs(product - speed) < 1e-6, f"speed={speed}, stages={stages}, product={product}"
def test_single_stage_at_min(self):
"""最小值边界单级."""
stages = SpeedEngine._split_atempo_stages(0.5)
assert len(stages) == 1
def test_split_atempo_all_in_range(self):
"""所有级都应该在 0.5~2.0 范围内."""
test_cases = [0.25, 0.3, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]
for speed in test_cases:
def test_multi_stage_double_speed(self):
"""4x 需要两级."""
stages = SpeedEngine._split_atempo_stages(4.0)
assert len(stages) == 2
assert abs(stages[0] * stages[1] - 4.0) < 0.01
def test_multi_stage_half_speed(self):
"""0.25x 需要两级."""
stages = SpeedEngine._split_atempo_stages(0.25)
assert len(stages) == 2
assert abs(stages[0] * stages[1] - 0.25) < 0.01
def test_all_stages_within_valid_range(self):
"""所有分级都在有效范围内."""
for speed in [0.25, 0.3, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0]:
stages = SpeedEngine._split_atempo_stages(speed)
for s in stages:
assert 0.5 <= s <= 2.0, f"speed={speed}, stage={s} out of range"
# ─── SpeedEngine 时长计算 ────────────────────────────────────
class TestAdjustDuration:
"""adjust_duration 时长计算测试."""
class TestSpeedEngineDuration:
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_same_duration(self):
config = SpeedConfig(speed=1.0)
assert self.engine.adjust_duration(10.0, config) == 10.0
def test_original_speed_unchanged(self):
"""原速时长不变."""
result = self.engine.adjust_duration(100.0, SpeedConfig(speed=1.0))
assert result == 100.0
def test_double_speed_half_duration(self):
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(10.0, config) == 5.0
"""2倍速时长减半."""
result = self.engine.adjust_duration(100.0, SpeedConfig(speed=2.0))
assert result == 50.0
def test_half_speed_double_duration(self):
config = SpeedConfig(speed=0.5)
assert self.engine.adjust_duration(10.0, config) == 20.0
"""0.5倍速时长翻倍."""
result = self.engine.adjust_duration(100.0, SpeedConfig(speed=0.5))
assert result == 200.0
def test_quad_speed_quarter_duration(self):
config = SpeedConfig(speed=4.0)
assert self.engine.adjust_duration(10.0, config) == 2.5
def test_zero_duration_unchanged(self):
"""零时长不变."""
result = self.engine.adjust_duration(0.0, SpeedConfig(speed=2.0))
assert result == 0.0
def test_zero_duration(self):
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(0.0, config) == 0.0
def test_negative_duration_unchanged(self):
"""负时长不变(异常值保护)."""
result = self.engine.adjust_duration(-10.0, SpeedConfig(speed=2.0))
assert result == -10.0
def test_negative_duration(self):
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(-1.0, config) == -1.0
# ─── SpeedEngine 便捷方法 ────────────────────────────────────
class TestSpeedEngineHelper:
def setup_method(self):
self.engine = SpeedEngine()
def test_build_clip_speed_filter_original(self):
v_f, a_f, cfg = self.engine.build_clip_speed_filter(1.0)
assert v_f == ""
assert a_f == ""
assert cfg.speed == 1.0
def test_build_clip_speed_filter_2x(self):
v_f, a_f, cfg = self.engine.build_clip_speed_filter(2.0)
assert "setpts=PTS/2.0" in v_f
assert "atempo=2.0" in a_f
assert cfg.speed == 2.0
def test_build_clip_speed_clamped(self):
_, _, cfg = self.engine.build_clip_speed_filter(100.0)
assert cfg.speed == MAX_SPEED
def test_resolve_clip_speed_default(self):
assert SpeedEngine.resolve_clip_speed({}) == 1.0
assert SpeedEngine.resolve_clip_speed(None) == 1.0
def test_resolve_clip_speed_zero_uses_global(self):
assert SpeedEngine.resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
def test_resolve_clip_speed_custom(self):
assert SpeedEngine.resolve_clip_speed({"playback_speed": 2.0}) == 2.0
def test_resolve_clip_speed_invalid_type(self):
assert SpeedEngine.resolve_clip_speed({"playback_speed": "fast"}) == 1.0
def test_quarter_speed(self):
"""0.25倍速时长4倍."""
result = self.engine.adjust_duration(60.0, SpeedConfig(speed=0.25))
assert abs(result - 240.0) < 0.01