From ea2cd42c596392a77e974d9010a9bd37bcdb27f7 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 27 Jul 2026 07:59:47 +0800 Subject: [PATCH] =?UTF-8?q?test(wave118):=20=E6=8A=BD=E7=A6=BBchroma=5Fkey?= =?UTF-8?q?=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2045=E5=8D=95?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离ChromaKeyConfig数据类、5个预设、颜色归一化、colorkey/chromakey滤镜构建到packages/domain/chroma_key_config.py - chroma_key_engine.py: 248→46行 (-81%),保留ChromaKeyEngine薄包装类+模块级向后兼容导入 - 45个纯逻辑单测全绿,原有91个测试无回归 - 合计136 passed --- .../video_processing/chroma_key_engine.py | 240 ++------------- packages/domain/chroma_key_config.py | 287 ++++++++++++++++++ tests/unit/test_chroma_key_config.py | 281 +++++++++++++++++ 3 files changed, 587 insertions(+), 221 deletions(-) create mode 100755 packages/domain/chroma_key_config.py create mode 100755 tests/unit/test_chroma_key_config.py diff --git a/apps/worker/video_processing/chroma_key_engine.py b/apps/worker/video_processing/chroma_key_engine.py index 5176f95f4..fb1923fcb 100755 --- a/apps/worker/video_processing/chroma_key_engine.py +++ b/apps/worker/video_processing/chroma_key_engine.py @@ -2,129 +2,31 @@ 支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。 -使用方式: - config = ChromaKeyConfig(key_color="#00FF00", similarity=0.3, blend=0.1) - engine = ChromaKeyEngine(config) - filter_str = engine.build_filter(input_label, output_label) - # 结果: [in]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[out] - -降级策略: - - 参数越界自动钳制 - - 素材格式不支持时跳过(调用方捕获异常) +注:核心领域模型已抽离到 packages/domain/chroma_key_config.py, +本模块保留薄包装层,确保向后兼容。 """ from __future__ import annotations import logging -import re -from dataclasses import dataclass from typing import Optional +from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容 + CHROMA_KEY_PRESETS, + ChromaKeyConfig, + apply_chroma_key_if_needed, + build_chromakey_filter as _build_chromakey_filter_base, + build_colorkey_filter as _build_colorkey_filter_base, + normalize_color as _normalize_color_base, +) + logger = logging.getLogger(__name__) -# ── 配置模型 ────────────────────────────────────────────────────────────────── - - -@dataclass -class ChromaKeyConfig: - """绿幕抠像配置。 - - Attributes: - enabled: 是否启用抠像 - key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名 - similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大 - blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和 - spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光 - """ - - enabled: bool = False - key_color: str = "#00FF00" - similarity: float = 0.3 - blend: float = 0.1 - spill_suppress: float = 0.0 - - @classmethod - def from_dict(cls, data: dict | None) -> "ChromaKeyConfig": - """从字典解析配置,参数越界自动钳制。""" - if not data or not data.get("enabled", False): - return cls(enabled=False) - - key_color = str(data.get("key_color", "#00FF00")).strip() - - def _safe_float(val, default): - try: - return float(val) - except (TypeError, ValueError): - return default - - similarity = _safe_float(data.get("similarity", 0.3), 0.3) - blend = _safe_float(data.get("blend", 0.1), 0.1) - spill_suppress = _safe_float(data.get("spill_suppress", 0.0), 0.0) - - # 钳制到合法范围 - similarity = max(0.01, min(1.0, similarity)) - blend = max(0.0, min(1.0, blend)) - spill_suppress = max(0.0, min(1.0, spill_suppress)) - - return cls( - enabled=True, - key_color=key_color, - similarity=similarity, - blend=blend, - spill_suppress=spill_suppress, - ) - - def has_effect(self) -> bool: - """判断是否有实际抠像效果。""" - return self.enabled and self.similarity > 0 - - -# ── 预设配置 ────────────────────────────────────────────────────────────────── - -# 常见绿幕/蓝幕预设 -CHROMA_KEY_PRESETS = { - "green_screen": { - "key_color": "#00FF00", - "similarity": 0.3, - "blend": 0.1, - "spill_suppress": 0.5, - }, - "blue_screen": { - "key_color": "#0000FF", - "similarity": 0.3, - "blend": 0.1, - "spill_suppress": 0.5, - }, - "red_screen": { - "key_color": "#FF0000", - "similarity": 0.3, - "blend": 0.1, - "spill_suppress": 0.0, - }, - "precise_green": { - "key_color": "#00FF00", - "similarity": 0.2, - "blend": 0.05, - "spill_suppress": 0.3, - }, - "soft_green": { - "key_color": "#00FF00", - "similarity": 0.45, - "blend": 0.2, - "spill_suppress": 0.5, - }, -} - - -# ── 引擎实现 ────────────────────────────────────────────────────────────────── - - class ChromaKeyEngine: - """绿幕抠像引擎。 + """绿幕抠像引擎. - 基于 FFmpeg colorkey 滤镜实现,将指定颜色变为透明。 - 适用于绿幕/蓝幕视频的背景去除,配合画中画或 overlay 实现虚拟背景。 + 薄包装层,实际逻辑委托给 packages.domain.chroma_key_config。 """ def __init__(self, config: ChromaKeyConfig): @@ -132,117 +34,13 @@ class ChromaKeyEngine: @staticmethod def _normalize_color(color_str: str) -> str: - """将颜色字符串转为 FFmpeg colorkey 接受的格式。 - - 支持: - - "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB - - "0xRRGGBB" → 直接使用 - - 颜色名(green/blue/red/black/white 等)→ 直接透传 - """ - color = color_str.strip() - - # hex 格式 - hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color) - if hex_match: - return f"0x{hex_match.group(1).upper()}" - - # 已经是 0x 格式 - if color.lower().startswith("0x"): - return color.upper() - - # 颜色名直接透传(FFmpeg 支持常见颜色名) - return color + """将颜色字符串转为 FFmpeg colorkey 接受的格式.""" + return _normalize_color_base(color_str) def build_filter(self, input_label: str, output_label: str) -> str: - """构建 colorkey 滤镜字符串。 - - Args: - input_label: 输入标签,如 "[0:v]" 或 "[v0]" - output_label: 输出标签,如 "[ck0]" - - Returns: - FFmpeg 滤镜字符串,如 "[v0]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[ck0]" - - Raises: - ValueError: 配置无效时抛出(调用方应捕获并降级) - """ - if not self.config.has_effect(): - # 无效果,直接直通 - return f"{input_label}copy{output_label}" - - color = self._normalize_color(self.config.key_color) - similarity = self.config.similarity - blend = self.config.blend - - # 基础 colorkey 滤镜 - parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"] - - # 溢色抑制(通过 colorchannelmixer 降低绿色通道增益) - if self.config.spill_suppress > 0: - # 降低绿通道增益,减少绿幕反光溢出 - spill = self.config.spill_suppress - # 绿通道增益 = 1 - spill_factor - g_gain = max(0.3, 1.0 - spill * 0.7) - # 同时稍微提升红和蓝来补偿色偏 - r_gain = 1.0 + spill * 0.15 - b_gain = 1.0 + spill * 0.15 - parts.append(f"colorchannelmixer=" f"rr={r_gain}:" f"gg={g_gain}:" f"bb={b_gain}:" f"aa=1") - - filter_str = f"{input_label}{','.join(parts)}{output_label}" - return filter_str + """构建 colorkey 滤镜字符串.""" + return _build_colorkey_filter_base(self.config, input_label, output_label) def build_filter_chromakey(self, input_label: str, output_label: str) -> str: - """使用 chromakey 滤镜(更高级的版本,支持更多参数)。 - - 注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜, - 优先使用 colorkey(兼容性更好)。 - - Args: - input_label: 输入标签 - output_label: 输出标签 - - Returns: - FFmpeg 滤镜字符串 - """ - if not self.config.has_effect(): - return f"{input_label}copy{output_label}" - - color = self._normalize_color(self.config.key_color) - similarity = self.config.similarity - blend = self.config.blend - - return f"{input_label}" f"chromakey=color={color}:similarity={similarity}:blend={blend}" f"{output_label}" - - -def apply_chroma_key_if_needed( - clip_config: dict | None, - input_label: str, - output_label: str, -) -> Optional[str]: - """便捷函数:根据 clip 配置判断是否需要应用绿幕抠像。 - - Args: - clip_config: clip 的 config 字典 - input_label: 输入标签 - output_label: 输出标签 - - Returns: - 滤镜字符串,不需要抠像时返回 None - """ - if not clip_config: - return None - - chroma_key_data = clip_config.get("chroma_key") - if not chroma_key_data: - return None - - try: - config = ChromaKeyConfig.from_dict(chroma_key_data) - if not config.has_effect(): - return None - - engine = ChromaKeyEngine(config) - return engine.build_filter(input_label, output_label) - except Exception as e: - logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e) - return None + """使用 chromakey 滤镜(更高级的版本,支持更多参数).""" + return _build_chromakey_filter_base(self.config, input_label, output_label) diff --git a/packages/domain/chroma_key_config.py b/packages/domain/chroma_key_config.py new file mode 100755 index 000000000..3d112b117 --- /dev/null +++ b/packages/domain/chroma_key_config.py @@ -0,0 +1,287 @@ +"""绿幕抠像配置领域模型 — 纯逻辑,无FFmpeg依赖. + +抽离自 chroma_key_engine.py,包含: +- ChromaKeyConfig 数据类(解析/钳制/效果判断) +- 预设配置(绿幕/蓝幕/红幕等) +- 颜色归一化 +- colorkey / chromakey 滤镜构建 +- 便捷函数(apply_chroma_key_if_needed) +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + + +# ── 预设配置 ────────────────────────────────────────────────────────────────── + +# 常见绿幕/蓝幕预设 +CHROMA_KEY_PRESETS: dict[str, dict[str, Any]] = { + "green_screen": { + "key_color": "#00FF00", + "similarity": 0.3, + "blend": 0.1, + "spill_suppress": 0.5, + }, + "blue_screen": { + "key_color": "#0000FF", + "similarity": 0.3, + "blend": 0.1, + "spill_suppress": 0.5, + }, + "red_screen": { + "key_color": "#FF0000", + "similarity": 0.3, + "blend": 0.1, + "spill_suppress": 0.0, + }, + "precise_green": { + "key_color": "#00FF00", + "similarity": 0.2, + "blend": 0.05, + "spill_suppress": 0.3, + }, + "soft_green": { + "key_color": "#00FF00", + "similarity": 0.45, + "blend": 0.2, + "spill_suppress": 0.5, + }, +} + +VALID_PRESETS = set(CHROMA_KEY_PRESETS.keys()) + +# 参数范围 +MIN_SIMILARITY = 0.01 +MAX_SIMILARITY = 1.0 +MIN_BLEND = 0.0 +MAX_BLEND = 1.0 +MIN_SPILL_SUPPRESS = 0.0 +MAX_SPILL_SUPPRESS = 1.0 + +# 默认值 +DEFAULT_KEY_COLOR = "#00FF00" +DEFAULT_SIMILARITY = 0.3 +DEFAULT_BLEND = 0.1 +DEFAULT_SPILL_SUPPRESS = 0.0 + + +# ── 配置模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class ChromaKeyConfig: + """绿幕抠像配置. + + Attributes: + enabled: 是否启用抠像 + key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名 + similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大 + blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和 + spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光 + """ + + enabled: bool = False + key_color: str = DEFAULT_KEY_COLOR + similarity: float = DEFAULT_SIMILARITY + blend: float = DEFAULT_BLEND + spill_suppress: float = DEFAULT_SPILL_SUPPRESS + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> ChromaKeyConfig: + """从字典解析配置,参数越界自动钳制.""" + if not data or not data.get("enabled", False): + return cls(enabled=False) + + key_color = str(data.get("key_color", DEFAULT_KEY_COLOR)).strip() + + def _safe_float(val: Any, default: float) -> float: + try: + return float(val) + except (TypeError, ValueError): + return default + + similarity = _safe_float(data.get("similarity", DEFAULT_SIMILARITY), DEFAULT_SIMILARITY) + blend = _safe_float(data.get("blend", DEFAULT_BLEND), DEFAULT_BLEND) + spill_suppress = _safe_float(data.get("spill_suppress", DEFAULT_SPILL_SUPPRESS), DEFAULT_SPILL_SUPPRESS) + + # 钳制到合法范围 + similarity = max(MIN_SIMILARITY, min(MAX_SIMILARITY, similarity)) + blend = max(MIN_BLEND, min(MAX_BLEND, blend)) + spill_suppress = max(MIN_SPILL_SUPPRESS, min(MAX_SPILL_SUPPRESS, spill_suppress)) + + return cls( + enabled=True, + key_color=key_color, + similarity=similarity, + blend=blend, + spill_suppress=spill_suppress, + ) + + @classmethod + def from_preset(cls, preset_name: str) -> ChromaKeyConfig | None: + """从预设名称创建配置.""" + preset = CHROMA_KEY_PRESETS.get(preset_name) + if not preset: + return None + return cls( + enabled=True, + key_color=preset["key_color"], + similarity=preset["similarity"], + blend=preset["blend"], + spill_suppress=preset["spill_suppress"], + ) + + def has_effect(self) -> bool: + """判断是否有实际抠像效果.""" + return self.enabled and self.similarity > 0 + + def validate(self) -> tuple[bool, str]: + """校验配置是否有效.""" + if not self.enabled: + return True, "" + + if not self.key_color: + return False, "key_color 不能为空" + + if not (MIN_SIMILARITY <= self.similarity <= MAX_SIMILARITY): + return False, f"similarity 必须在 {MIN_SIMILARITY}~{MAX_SIMILARITY} 之间" + + if not (MIN_BLEND <= self.blend <= MAX_BLEND): + return False, f"blend 必须在 {MIN_BLEND}~{MAX_BLEND} 之间" + + if not (MIN_SPILL_SUPPRESS <= self.spill_suppress <= MAX_SPILL_SUPPRESS): + return False, f"spill_suppress 必须在 {MIN_SPILL_SUPPRESS}~{MAX_SPILL_SUPPRESS} 之间" + + return True, "" + + +# ── 颜色归一化 ──────────────────────────────────────────────────────────────── + + +def normalize_color(color_str: str) -> str: + """将颜色字符串转为 FFmpeg colorkey 接受的格式. + + 支持: + - "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB + - "0xRRGGBB" → 直接使用 + - 颜色名(green/blue/red/black/white 等)→ 直接透传 + """ + color = color_str.strip() + + # hex 格式 + hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color) + if hex_match: + return f"0x{hex_match.group(1).upper()}" + + # 已经是 0x 格式 + if color.lower().startswith("0x"): + return color.upper() + + # 颜色名直接透传(FFmpeg 支持常见颜色名) + return color + + +# ── 滤镜构建 ──────────────────────────────────────────────────────────────── + + +def build_colorkey_filter( + config: ChromaKeyConfig, + input_label: str, + output_label: str, +) -> str: + """构建 colorkey 滤镜字符串. + + Args: + config: 抠像配置 + input_label: 输入标签,如 "[0:v]" 或 "[v0]" + output_label: 输出标签,如 "[ck0]" + + Returns: + FFmpeg 滤镜字符串 + """ + if not config.has_effect(): + return f"{input_label}copy{output_label}" + + color = normalize_color(config.key_color) + similarity = config.similarity + blend = config.blend + + # 基础 colorkey 滤镜 + parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"] + + # 溢色抑制(通过 colorchannelmixer 降低绿色通道增益) + if config.spill_suppress > 0: + spill = config.spill_suppress + g_gain = max(0.3, 1.0 - spill * 0.7) + r_gain = 1.0 + spill * 0.15 + b_gain = 1.0 + spill * 0.15 + parts.append(f"colorchannelmixer=rr={r_gain}:gg={g_gain}:bb={b_gain}:aa=1") + + return f"{input_label}{','.join(parts)}{output_label}" + + +def build_chromakey_filter( + config: ChromaKeyConfig, + input_label: str, + output_label: str, +) -> str: + """使用 chromakey 滤镜(更高级的版本,支持更多参数). + + 注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜, + 优先使用 colorkey(兼容性更好)。 + """ + if not config.has_effect(): + return f"{input_label}copy{output_label}" + + color = normalize_color(config.key_color) + similarity = config.similarity + blend = config.blend + + return f"{input_label}chromakey=color={color}:similarity={similarity}:blend={blend}{output_label}" + + +# ── 工具函数 ──────────────────────────────────────────────────────────────── + + +def apply_chroma_key_if_needed( + clip_config: dict[str, Any] | None, + input_label: str, + output_label: str, +) -> str | None: + """便捷函数:根据 clip 配置判断是否需要应用绿幕抠像. + + Args: + clip_config: clip 的 config 字典 + input_label: 输入标签 + output_label: 输出标签 + + Returns: + 滤镜字符串,不需要抠像时返回 None + """ + if not clip_config: + return None + + chroma_key_data = clip_config.get("chroma_key") + if not chroma_key_data: + return None + + try: + config = ChromaKeyConfig.from_dict(chroma_key_data) + if not config.has_effect(): + return None + + return build_colorkey_filter(config, input_label, output_label) + except Exception as e: + logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e) + return None + + +def get_preset_names() -> list[str]: + """获取所有预设名称列表.""" + return sorted(list(CHROMA_KEY_PRESETS.keys())) diff --git a/tests/unit/test_chroma_key_config.py b/tests/unit/test_chroma_key_config.py new file mode 100755 index 000000000..1c543d340 --- /dev/null +++ b/tests/unit/test_chroma_key_config.py @@ -0,0 +1,281 @@ +"""chroma_key_config 领域模型单测.""" + +from __future__ import annotations + +import pytest + +from packages.domain.chroma_key_config import ( + CHROMA_KEY_PRESETS, + ChromaKeyConfig, + apply_chroma_key_if_needed, + build_chromakey_filter, + build_colorkey_filter, + get_preset_names, + normalize_color, +) + +# ── ChromaKeyConfig.from_dict 测试 ──────────────────────────────────────── + + +class TestChromaKeyConfigFromDict: + def test_none_returns_disabled(self): + cfg = ChromaKeyConfig.from_dict(None) + assert cfg.enabled is False + + def test_empty_dict_returns_disabled(self): + cfg = ChromaKeyConfig.from_dict({}) + assert cfg.enabled is False + + def test_disabled_returns_disabled(self): + cfg = ChromaKeyConfig.from_dict({"enabled": False}) + assert cfg.enabled is False + + def test_enabled_default_params(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True}) + assert cfg.enabled is True + assert cfg.key_color == "#00FF00" + assert cfg.similarity == 0.3 + assert cfg.blend == 0.1 + assert cfg.spill_suppress == 0.0 + + def test_custom_params(self): + cfg = ChromaKeyConfig.from_dict( + { + "enabled": True, + "key_color": "#0000FF", + "similarity": 0.5, + "blend": 0.2, + "spill_suppress": 0.4, + } + ) + assert cfg.key_color == "#0000FF" + assert cfg.similarity == 0.5 + assert cfg.blend == 0.2 + assert cfg.spill_suppress == 0.4 + + def test_similarity_clamped_low(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "similarity": 0.001}) + assert cfg.similarity == 0.01 + + def test_similarity_clamped_high(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "similarity": 2.0}) + assert cfg.similarity == 1.0 + + def test_blend_clamped_low(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "blend": -0.5}) + assert cfg.blend == 0.0 + + def test_blend_clamped_high(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "blend": 1.5}) + assert cfg.blend == 1.0 + + def test_spill_suppress_clamped(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "spill_suppress": 2.0}) + assert cfg.spill_suppress == 1.0 + + def test_invalid_similarity_type_uses_default(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "similarity": "high"}) + assert cfg.similarity == 0.3 + + def test_key_color_stripped(self): + cfg = ChromaKeyConfig.from_dict({"enabled": True, "key_color": " #00FF00 "}) + assert cfg.key_color == "#00FF00" + + +# ── from_preset 测试 ────────────────────────────────────────────────────── + + +class TestFromPreset: + def test_green_screen_preset(self): + cfg = ChromaKeyConfig.from_preset("green_screen") + assert cfg is not None + assert cfg.enabled is True + assert cfg.key_color == "#00FF00" + assert cfg.similarity == 0.3 + + def test_blue_screen_preset(self): + cfg = ChromaKeyConfig.from_preset("blue_screen") + assert cfg is not None + assert cfg.key_color == "#0000FF" + + def test_invalid_preset_returns_none(self): + assert ChromaKeyConfig.from_preset("nonexistent") is None + + def test_all_presets_valid(self): + for name in CHROMA_KEY_PRESETS: + cfg = ChromaKeyConfig.from_preset(name) + assert cfg is not None + assert cfg.enabled is True + + +# ── has_effect / validate 测试 ──────────────────────────────────────────── + + +class TestHasEffectAndValidate: + def test_disabled_no_effect(self): + cfg = ChromaKeyConfig(enabled=False) + assert cfg.has_effect() is False + + def test_enabled_has_effect(self): + cfg = ChromaKeyConfig(enabled=True, similarity=0.3) + assert cfg.has_effect() is True + + def test_zero_similarity_no_effect(self): + cfg = ChromaKeyConfig(enabled=True, similarity=0.0) + # similarity 被钳制后为 0.01,所以应该有效果 + # 等等,from_dict 才会钳制,直接构造不会 + assert cfg.has_effect() is False + + def test_validate_disabled_valid(self): + cfg = ChromaKeyConfig(enabled=False) + ok, msg = cfg.validate() + assert ok is True + assert msg == "" + + def test_validate_enabled_valid(self): + cfg = ChromaKeyConfig(enabled=True, key_color="#00FF00") + ok, msg = cfg.validate() + assert ok is True + + def test_validate_empty_color_invalid(self): + cfg = ChromaKeyConfig(enabled=True, key_color="") + ok, msg = cfg.validate() + assert ok is False + assert "key_color" in msg + + def test_validate_similarity_out_of_range(self): + cfg = ChromaKeyConfig(enabled=True, similarity=2.0) + ok, msg = cfg.validate() + assert ok is False + assert "similarity" in msg + + +# ── normalize_color 测试 ────────────────────────────────────────────────── + + +class TestNormalizeColor: + def test_hex_with_hash(self): + assert normalize_color("#00FF00") == "0x00FF00" + + def test_hex_lowercase(self): + assert normalize_color("#00ff00") == "0x00FF00" + + def test_hex_without_hash(self): + assert normalize_color("00FF00") == "0x00FF00" + + def test_hex_with_alpha(self): + assert normalize_color("#00FF00FF") == "0x00FF00" + + def test_already_0x_format(self): + assert normalize_color("0x00FF00") == "0X00FF00" + + def test_0x_lowercase(self): + assert normalize_color("0x00ff00") == "0X00FF00" + + def test_color_name_passthrough(self): + assert normalize_color("green") == "green" + assert normalize_color("blue") == "blue" + + def test_whitespace_stripped(self): + assert normalize_color(" #FF0000 ") == "0xFF0000" + + +# ── build_colorkey_filter 测试 ──────────────────────────────────────────── + + +class TestBuildColorkeyFilter: + def test_disabled_returns_copy(self): + cfg = ChromaKeyConfig(enabled=False) + result = build_colorkey_filter(cfg, "[in]", "[out]") + assert "copy" in result + assert "[in]" in result + assert "[out]" in result + + def test_basic_colorkey(self): + cfg = ChromaKeyConfig(enabled=True, key_color="#00FF00", similarity=0.3, blend=0.1) + result = build_colorkey_filter(cfg, "[v]", "[ck]") + assert "colorkey=" in result + assert "color=0x00FF00" in result + assert "similarity=0.3" in result + assert "blend=0.1" in result + assert "[v]" in result + assert "[ck]" in result + + def test_with_spill_suppress(self): + cfg = ChromaKeyConfig(enabled=True, key_color="#00FF00", spill_suppress=0.5) + result = build_colorkey_filter(cfg, "[in]", "[out]") + assert "colorchannelmixer=" in result + assert "rr=" in result + assert "gg=" in result + assert "bb=" in result + + def test_no_spill_suppress_no_colorchannelmixer(self): + cfg = ChromaKeyConfig(enabled=True, spill_suppress=0.0) + result = build_colorkey_filter(cfg, "[in]", "[out]") + assert "colorchannelmixer" not in result + + +# ── build_chromakey_filter 测试 ─────────────────────────────────────────── + + +class TestBuildChromakeyFilter: + def test_disabled_returns_copy(self): + cfg = ChromaKeyConfig(enabled=False) + result = build_chromakey_filter(cfg, "[in]", "[out]") + assert "copy" in result + + def test_basic_chromakey(self): + cfg = ChromaKeyConfig(enabled=True, key_color="#00FF00", similarity=0.3, blend=0.1) + result = build_chromakey_filter(cfg, "[v]", "[ck]") + assert "chromakey=" in result + assert "color=0x00FF00" in result + assert "similarity=0.3" in result + assert "blend=0.1" in result + + def test_contains_input_and_output_labels(self): + cfg = ChromaKeyConfig(enabled=True) + result = build_chromakey_filter(cfg, "[in_v]", "[out_v]") + assert "[in_v]" in result + assert "[out_v]" in result + + +# ── apply_chroma_key_if_needed 测试 ─────────────────────────────────────── + + +class TestApplyChromaKeyIfNeeded: + def test_none_config_returns_none(self): + assert apply_chroma_key_if_needed(None, "[in]", "[out]") is None + + def test_no_chroma_key_returns_none(self): + assert apply_chroma_key_if_needed({}, "[in]", "[out]") is None + + def test_disabled_chroma_key_returns_none(self): + config = {"chroma_key": {"enabled": False}} + assert apply_chroma_key_if_needed(config, "[in]", "[out]") is None + + def test_enabled_chroma_key_returns_filter(self): + config = {"chroma_key": {"enabled": True, "key_color": "#00FF00"}} + result = apply_chroma_key_if_needed(config, "[in]", "[out]") + assert result is not None + assert "colorkey" in result + + def test_invalid_config_handles_exception(self): + # 传入无效配置触发异常,应该返回 None 而不是抛出 + config = {"chroma_key": "invalid_string"} + result = apply_chroma_key_if_needed(config, "[in]", "[out]") + assert result is None + + +# ── 预设工具函数测试 ─────────────────────────────────────────────────────── + + +class TestPresetUtils: + def test_get_preset_names_returns_sorted_list(self): + names = get_preset_names() + assert isinstance(names, list) + assert len(names) == len(CHROMA_KEY_PRESETS) + assert names == sorted(names) + + def test_all_preset_names_in_presets_dict(self): + for name in get_preset_names(): + assert name in CHROMA_KEY_PRESETS -- 2.54.0