diff --git a/apps/worker/video_processing/color_grade_engine.py b/apps/worker/video_processing/color_grade_engine.py new file mode 100755 index 000000000..afd6d852a --- /dev/null +++ b/apps/worker/video_processing/color_grade_engine.py @@ -0,0 +1,416 @@ +"""滤镜调色引擎 — 基于 FFmpeg eq + colorbalance + hue + curves 滤镜组合实现画面色彩调整. + +支持能力: +- 基础调色参数:亮度、对比度、饱和度、色温、色调 +- 8种风格预设:清新、日系、复古、电影、胶片、黑白、暖色、冷色 +- 分段应用:每个 clip 可独立设置不同滤镜 +- 降级策略:参数越界自动钳制,不阻断渲染 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +# ── 预设滤镜包 ──────────────────────────────────────────────────────────────── + +# 预设名称常量 +PRESET_FRESH = "fresh" # 清新 +PRESET_JAPANESE = "japanese" # 日系 +PRESET_VINTAGE = "vintage" # 复古 +PRESET_CINEMA = "cinema" # 电影 +PRESET_FILM = "film" # 胶片 +PRESET_BW = "black_white" # 黑白 +PRESET_WARM = "warm" # 暖色 +PRESET_COOL = "cool" # 冷色 + +VALID_PRESETS = { + PRESET_FRESH, + PRESET_JAPANESE, + PRESET_VINTAGE, + PRESET_CINEMA, + PRESET_FILM, + PRESET_BW, + PRESET_WARM, + PRESET_COOL, +} + +# 预设名称 → 中文显示名 +PRESET_DISPLAY_NAMES = { + PRESET_FRESH: "清新", + PRESET_JAPANESE: "日系", + PRESET_VINTAGE: "复古", + PRESET_CINEMA: "电影", + PRESET_FILM: "胶片", + PRESET_BW: "黑白", + PRESET_WARM: "暖色", + PRESET_COOL: "冷色", +} + +# 预设参数配置 +# 每个预设包含:brightness, contrast, saturation, temperature, hue +# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180 +PRESET_PARAMS: dict[str, dict[str, float]] = { + PRESET_FRESH: { + # 清新:提亮、高饱和、偏冷、微微调 + "brightness": 8, + "contrast": 10, + "saturation": 120, + "temperature": -8, + "hue": 5, + }, + PRESET_JAPANESE: { + # 日系:低对比、低饱和、偏暖、偏黄绿 + "brightness": 12, + "contrast": -15, + "saturation": 70, + "temperature": 10, + "hue": -5, + }, + PRESET_VINTAGE: { + # 复古:低饱和、偏黄、对比度适中、偏暖 + "brightness": -5, + "contrast": 5, + "saturation": 60, + "temperature": 25, + "hue": -8, + }, + PRESET_CINEMA: { + # 电影:高对比、低饱和、偏冷蓝、暗角感 + "brightness": -8, + "contrast": 20, + "saturation": 75, + "temperature": -15, + "hue": -3, + }, + PRESET_FILM: { + # 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟) + "brightness": -3, + "contrast": 12, + "saturation": 95, + "temperature": 15, + "hue": -2, + }, + PRESET_BW: { + # 黑白:饱和度为0,对比度略高 + "brightness": 0, + "contrast": 15, + "saturation": 0, + "temperature": 0, + "hue": 0, + }, + PRESET_WARM: { + # 暖色:高色温、偏红黄 + "brightness": 5, + "contrast": 8, + "saturation": 110, + "temperature": 30, + "hue": -5, + }, + PRESET_COOL: { + # 冷色:低色温、偏蓝青 + "brightness": 3, + "contrast": 8, + "saturation": 105, + "temperature": -25, + "hue": 8, + }, +} + + +# ── 参数范围 ────────────────────────────────────────────────────────────────── + +PARAM_RANGES = { + "brightness": (-100.0, 100.0), + "contrast": (-100.0, 100.0), + "saturation": (0.0, 200.0), + "temperature": (-100.0, 100.0), + "hue": (-180.0, 180.0), +} + +# 默认值(零调整) +DEFAULT_PARAMS = { + "brightness": 0.0, + "contrast": 0.0, + "saturation": 100.0, + "temperature": 0.0, + "hue": 0.0, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class ColorGradeConfig: + """色彩调色配置. + + 优先级:自定义参数 > 预设参数 + 即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖 + """ + + enabled: bool = False + preset: str = "" # 预设名称,空表示不使用预设 + # 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值) + brightness: float | None = None + contrast: float | None = None + saturation: float | None = None + temperature: float | None = None + hue: float | None = None + + def resolve_params(self) -> dict[str, float]: + """解析最终调色参数(预设 + 自定义覆盖 + 边界钳制). + + Returns: + 包含 brightness, contrast, saturation, temperature, hue 的参数字典 + """ + # 1. 从默认值开始 + params = dict(DEFAULT_PARAMS) + + # 2. 应用预设 + if self.preset and self.preset in PRESET_PARAMS: + params.update(PRESET_PARAMS[self.preset]) + + # 3. 应用自定义覆盖 + if self.brightness is not None: + params["brightness"] = self.brightness + if self.contrast is not None: + params["contrast"] = self.contrast + if self.saturation is not None: + params["saturation"] = self.saturation + if self.temperature is not None: + params["temperature"] = self.temperature + if self.hue is not None: + params["hue"] = self.hue + + # 4. 边界钳制 + for key, (min_val, max_val) in PARAM_RANGES.items(): + params[key] = max(min_val, min(max_val, params[key])) + + return params + + def has_effect(self) -> bool: + """判断是否有实际调色效果(所有参数都是默认值则无效果). + + 用于优化:无效果时跳过滤镜,不浪费性能。 + """ + params = self.resolve_params() + for key, default in DEFAULT_PARAMS.items(): + if abs(params[key] - default) > 0.001: + return True + return False + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig": + """从字典解析配置.""" + if not data or not data.get("enabled", False): + return cls(enabled=False) + + preset = data.get("preset", "") + if preset and preset not in VALID_PRESETS: + logger.warning("未知的调色预设: %s,忽略预设", preset) + preset = "" + + def _get_float(key: str) -> float | None: + val = data.get(key) + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + try: + return cls( + enabled=True, + preset=preset, + brightness=_get_float("brightness"), + contrast=_get_float("contrast"), + saturation=_get_float("saturation"), + temperature=_get_float("temperature"), + hue=_get_float("hue"), + ) + except Exception as e: + logger.warning("调色配置解析失败: %s,使用默认配置", e) + return cls(enabled=False) + + +# ── 调色引擎 ────────────────────────────────────────────────────────────────── + + +class ColorGradeEngine: + """滤镜调色引擎 — 生成 FFmpeg 调色滤镜链. + + 滤镜组合策略: + 1. eq 滤镜:调整亮度(brightness)、对比度(contrast)、饱和度(saturation) + 2. colorbalance 滤镜:调整色温(通过调整红/青、黄/蓝平衡) + 3. hue 滤镜:调整色调 + + 所有参数转换公式: + - brightness: 用户值 -100~100 → FFmpeg eq brightness -1.0~1.0 + - contrast: 用户值 -100~100 → FFmpeg eq contrast -1000~1000(非线性映射) + - saturation: 用户值 0~200 → FFmpeg eq saturation 0.0~2.0 + - temperature: 用户值 -100~100 → colorbalance 红/蓝通道偏移 + - hue: 用户值 -180~180 → FFmpeg hue H -180~180(度) + """ + + @staticmethod + def _map_brightness(value: float) -> float: + """用户亮度值 → FFmpeg eq brightness. + + 用户范围 -100~100 → FFmpeg范围 -1.0~1.0 + """ + return value / 100.0 + + @staticmethod + def _map_contrast(value: float) -> float: + """用户对比度值 → FFmpeg eq contrast. + + 用户范围 -100~100 → FFmpeg范围 -2.0~2.0 + 注:FFmpeg eq 的 contrast 公式为 linear gain,1.0 为原始 + -2 ~ 2 的范围对应 ~-1000 ~ 1000 的老式定义的约 -66% ~ +100% + """ + if value >= 0: + # 正向:0~100 → 1.0~2.0 + return 1.0 + value / 100.0 + else: + # 负向:-100~0 → 0.0~1.0 + return 1.0 + value / 100.0 # value为负数,相当于 1.0 - |value|/100 + + @staticmethod + def _map_saturation(value: float) -> float: + """用户饱和度 → FFmpeg eq saturation. + + 用户范围 0~200 → FFmpeg范围 0.0~2.0 + """ + return value / 100.0 + + @staticmethod + def _map_temperature(value: float) -> tuple[float, float, float]: + """用户色温值 → colorbalance 三个通道参数. + + 返回:(red, green, blue) — 每个通道 -1.0~1.0 的偏移 + + 色温为正(暖):增加红、减蓝 + 色温为负(冷):减红、加蓝 + """ + # -100~100 → -0.5~0.5 + normalized = value / 200.0 + + if normalized >= 0: + # 暖色调:红+,绿微+,蓝- + red = normalized * 0.8 + green = normalized * 0.3 + blue = -normalized * 0.8 + else: + # 冷色调:红-,绿微+,蓝+ + red = normalized * 0.8 # 负数 + green = -normalized * 0.2 # 正数(冷色也加点绿让它偏青) + blue = -normalized * 0.8 # 正数 + + return (red, green, blue) + + @staticmethod + def _map_hue(value: float) -> float: + """用户色调值 → FFmpeg hue滤镜角度. + + 用户范围 -180~180 → FFmpeg H -180~180 + """ + return value + + @classmethod + def build_filter(cls, config: ColorGradeConfig, input_label: str = "", output_label: str = "") -> str: + """构建调色滤镜字符串. + + Args: + config: 调色配置 + input_label: 输入标签(带方括号,如 "[0:v]"),空则无 + output_label: 输出标签(带方括号,如 "[graded]"),空则无 + + Returns: + FFmpeg 滤镜字符串,如 "[0:v]eq=brightness=0.1:contrast=1.2,hue=H=10[graded]" + """ + if not config.enabled or not config.has_effect(): + # 无效果时直通 + if input_label and output_label: + return f"{input_label}copy{output_label}" + return "" + + params = config.resolve_params() + filters: list[str] = [] + + # 1. eq 滤镜:亮度 + 对比度 + 饱和度 + eq_parts: list[str] = [] + brightness = cls._map_brightness(params["brightness"]) + contrast = cls._map_contrast(params["contrast"]) + saturation = cls._map_saturation(params["saturation"]) + + if abs(brightness) > 0.001: + eq_parts.append(f"brightness={brightness:.3f}") + if abs(contrast - 1.0) > 0.001: + eq_parts.append(f"contrast={contrast:.3f}") + if abs(saturation - 1.0) > 0.001: + eq_parts.append(f"saturation={saturation:.3f}") + + if eq_parts: + filters.append(f"eq={':'.join(eq_parts)}") + + # 2. colorbalance 滤镜:色温 + if abs(params["temperature"]) > 0.001: + red, green, blue = cls._map_temperature(params["temperature"]) + cb_parts = [] + # 调整阴影/中间调/高光的平衡(简化:全部统一调整) + if abs(red) > 0.001: + cb_parts.append(f"rs={red:.3f}") + cb_parts.append(f"rm={red:.3f}") + cb_parts.append(f"rh={red:.3f}") + if abs(green) > 0.001: + cb_parts.append(f"gs={green:.3f}") + cb_parts.append(f"gm={green:.3f}") + cb_parts.append(f"gh={green:.3f}") + if abs(blue) > 0.001: + cb_parts.append(f"bs={blue:.3f}") + cb_parts.append(f"bm={blue:.3f}") + cb_parts.append(f"bh={blue:.3f}") + if cb_parts: + filters.append(f"colorbalance={':'.join(cb_parts)}") + + # 3. hue 滤镜:色调 + if abs(params["hue"]) > 0.001: + hue_val = cls._map_hue(params["hue"]) + filters.append(f"hue=h={hue_val:.1f}") + + if not filters: + # 理论上不会到这里(has_effect 已判断),保险起见 + if input_label and output_label: + return f"{input_label}copy{output_label}" + return "" + + filter_str = ",".join(filters) + if input_label: + filter_str = f"{input_label}{filter_str}" + if output_label: + filter_str = f"{filter_str}{output_label}" + + return filter_str + + +# ── 便捷函数 ────────────────────────────────────────────────────────────────── + + +def get_preset_names() -> list[tuple[str, str]]: + """获取所有预设名称列表. + + Returns: + [(preset_key, display_name), ...] + """ + return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()] + + +def get_preset_params(preset: str) -> dict[str, float] | None: + """获取指定预设的参数.""" + return PRESET_PARAMS.get(preset) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 2a0575af7..5beb86909 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -28,6 +28,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any +from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine from video_processing.ffmpeg_utils import ( DEFAULT_FPS, DEFAULT_OUTPUT_HEIGHT, @@ -618,6 +619,13 @@ class UnifiedRenderService: filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase") filters.append(f"crop={self.output_width}:{self.output_height}") + # 调色滤镜 + color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade")) + if color_grade.enabled and color_grade.has_effect(): + grade_filter = ColorGradeEngine.build_filter(color_grade) + if grade_filter: + filters.append(grade_filter) + filters.append("setpts=PTS-STARTPTS") filters.append(f"fps={self.output_fps}") filters.append("format=yuv420p") @@ -831,6 +839,13 @@ class UnifiedRenderService: ) filters.append(f"crop={self.output_width}:{self.output_height}") + # 调色滤镜(每个 clip 独立的 color grade 配置) + color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade")) + if color_grade.enabled and color_grade.has_effect(): + grade_filter = ColorGradeEngine.build_filter(color_grade) + if grade_filter: + filters.append(grade_filter) + filters.append("setpts=PTS-STARTPTS") filters.append(f"fps={self.output_fps}") diff --git a/tests/unit/test_color_grade_engine.py b/tests/unit/test_color_grade_engine.py new file mode 100755 index 000000000..a3503f266 --- /dev/null +++ b/tests/unit/test_color_grade_engine.py @@ -0,0 +1,572 @@ +"""滤镜调色引擎单元测试.""" + +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, + ColorGradeConfig, + ColorGradeEngine, + get_preset_names, + get_preset_params, +) + +# ── 预设常量测试 ────────────────────────────────────────────────────────────── + + +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 + assert config.preset == "" + assert config.brightness is None + assert config.contrast is None + assert config.saturation is None + 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 == "" # 被清空 + + 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_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_invalid_value_returns_none(self): + """无效值应该返回None(不覆盖).""" + config = ColorGradeConfig.from_dict( + { + "enabled": True, + "brightness": "not_a_number", + } + ) + assert config.brightness is None + + +# ── ColorGradeConfig.resolve_params 测试 ────────────────────────────────────── + + +class TestResolveParams: + """参数解析与边界钳制测试.""" + + def test_default_params_when_empty(self): + """无预设无自定义时返回默认值.""" + config = ColorGradeConfig(enabled=True) + 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) + params = config.resolve_params() + preset = PRESET_PARAMS[PRESET_FRESH] + for key, val in preset.items(): + assert params[key] == val + + def test_custom_overrides_preset(self): + """自定义参数应该覆盖预设值.""" + config = ColorGradeConfig( + enabled=True, + preset=PRESET_FRESH, + brightness=50, # 覆盖预设的8 + ) + params = config.resolve_params() + assert params["brightness"] == 50 + # 其他参数还是预设值 + assert params["contrast"] == PRESET_PARAMS[PRESET_FRESH]["contrast"] + + def test_clamp_brightness_high(self): + """亮度超过上限应该被钳制.""" + config = ColorGradeConfig(enabled=True, brightness=200) + params = config.resolve_params() + assert params["brightness"] == 100 + + def test_clamp_brightness_low(self): + """亮度低于下限应该被钳制.""" + config = ColorGradeConfig(enabled=True, brightness=-200) + params = config.resolve_params() + assert params["brightness"] == -100 + + 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应该被钳制.""" + config = ColorGradeConfig(enabled=True, saturation=300) + params = config.resolve_params() + assert params["saturation"] == 200 + + def test_clamp_hue_high(self): + """色调超过180应该被钳制.""" + config = ColorGradeConfig(enabled=True, hue=270) + params = config.resolve_params() + assert params["hue"] == 180 + + def test_clamp_hue_low(self): + """色调低于-180应该被钳制.""" + config = ColorGradeConfig(enabled=True, hue=-270) + params = config.resolve_params() + assert params["hue"] == -180 + + def test_clamp_contrast(self): + """对比度越界应该被钳制.""" + config = ColorGradeConfig(enabled=True, contrast=150) + 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 测试 ────────────────────────────────────────── + + +class TestHasEffect: + """是否有实际效果判断测试.""" + + 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.""" + config = ColorGradeConfig(enabled=True) + assert not config.has_effect() + + def test_brightness_change_has_effect(self): + """亮度变化应该有效果.""" + config = ColorGradeConfig(enabled=True, brightness=10) + assert config.has_effect() + + def test_saturation_100_no_effect(self): + """饱和度100是默认值,无效果.""" + config = ColorGradeConfig(enabled=True, saturation=100) + assert not config.has_effect() + + 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() + + +# ── ColorGradeEngine 参数映射测试 ───────────────────────────────────────────── + + +class TestParameterMapping: + """FFmpeg参数映射测试.""" + + def test_brightness_mapping_zero(self): + """亮度0 → 0.0.""" + assert ColorGradeEngine._map_brightness(0) == 0.0 + + 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