feat: 绿幕抠像 + 音频降噪引擎(Chroma Key + Noise Reduction) (#303)
CI/CD Pipeline / Integration Tests (push) Failing after 1m13s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1540h36m52s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1540h36m56s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1541h8m31s
CI/CD Pipeline / Frontend Lint (push) Has been skipped

This commit was merged in pull request #303.
This commit is contained in:
2026-07-14 10:52:01 +08:00
parent 241760ef39
commit e9a6d19e00
5 changed files with 1118 additions and 4 deletions
+248
View File
@@ -0,0 +1,248 @@
"""绿幕抠像引擎 — 基于 FFmpeg colorkey / chromakey 滤镜.
支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。
使用方式:
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]
降级策略:
- 参数越界自动钳制
- 素材格式不支持时跳过(调用方捕获异常)
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Optional
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 实现虚拟背景。
"""
def __init__(self, config: ChromaKeyConfig):
self.config = config
@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
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
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
+229
View File
@@ -0,0 +1,229 @@
"""音频降噪引擎 — 基于 FFmpeg afftdn 滤镜.
支持对音频进行背景噪音消除、人声增强,适用于语音录制、采访等场景。
使用方式:
config = NoiseReductionConfig(level="medium")
engine = NoiseReductionEngine(config)
filter_str = engine.build_filter(input_label, output_label)
# 结果: [0:a]afftdn=nf=-25[out]
降级策略:
- 参数越界自动钳制
- FFmpeg 不支持 afftdn 时,调用方可捕获异常并跳过
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
class NoiseReductionLevel(str, Enum):
"""降噪等级预设。"""
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
MEDIUM = "medium" # 中度降噪,平衡效果和音质
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
CUSTOM = "custom" # 自定义参数
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB)
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
_LEVEL_PARAMS = {
NoiseReductionLevel.LOW: {
"nf": -35, # 噪音阈值(dB),越负越保守
"tn": -10, # 噪音频谱平滑度
"tr": 50, # 时间分辨率(ms)
},
NoiseReductionLevel.MEDIUM: {
"nf": -25,
"tn": -10,
"tr": 50,
},
NoiseReductionLevel.HIGH: {
"nf": -15,
"tn": -5,
"tr": 30,
},
}
# ── 配置模型 ──────────────────────────────────────────────────────────────────
@dataclass
class NoiseReductionConfig:
"""音频降噪配置。
Attributes:
enabled: 是否启用降噪
level: 降噪等级 low/medium/high/custom
noise_floor: 自定义噪音阈值(dB),仅 level=custom 时有效,范围 -60 ~ -5
voice_enhance: 是否启用人声增强
output_format: 输出格式描述(内部使用)
"""
enabled: bool = False
level: NoiseReductionLevel = NoiseReductionLevel.MEDIUM
noise_floor: float = -25.0 # dB
voice_enhance: bool = False
@classmethod
def from_dict(cls, data: dict | None) -> "NoiseReductionConfig":
"""从字典解析配置,参数越界自动钳制。"""
if not data or not data.get("enabled", False):
return cls(enabled=False)
level_str = str(data.get("level", "medium")).lower()
try:
level = NoiseReductionLevel(level_str)
except ValueError:
level = NoiseReductionLevel.MEDIUM
try:
noise_floor = float(data.get("noise_floor", -25.0))
except (TypeError, ValueError):
noise_floor = -25.0
voice_enhance = bool(data.get("voice_enhance", False))
# 钳制到合法范围
noise_floor = max(-60.0, min(-5.0, noise_floor))
return cls(
enabled=True,
level=level,
noise_floor=noise_floor,
voice_enhance=voice_enhance,
)
def has_effect(self) -> bool:
"""判断是否有实际降噪效果。"""
return self.enabled
def get_effective_noise_floor(self) -> float:
"""获取实际生效的噪音阈值(dB)。"""
if self.level == NoiseReductionLevel.CUSTOM:
return self.noise_floor
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[NoiseReductionLevel.MEDIUM])
return float(params["nf"])
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
class NoiseReductionEngine:
"""音频降噪引擎。
基于 FFmpeg afftdn(Audio FFt Denoiser)滤镜实现:
- 使用短时傅里叶变换分析音频频谱
- 识别并消除稳态背景噪音
- 保留人声等非稳态信号
"""
def __init__(self, config: NoiseReductionConfig):
self.config = config
def build_filter(self, input_label: str, output_label: str) -> str:
"""构建音频降噪滤镜字符串。
Args:
input_label: 输入标签,如 "[0:a]" 或 "[a0]"
output_label: 输出标签,如 "[nr0]"
Returns:
FFmpeg 滤镜字符串,如 "[a0]afftdn=nf=-25:tn=-10:tr=50[nr0]"
Raises:
ValueError: 配置无效时抛出(调用方应捕获并降级)
"""
if not self.config.has_effect():
return f"{input_label}anull{output_label}"
# 获取参数
if self.config.level == NoiseReductionLevel.CUSTOM:
nf = self.config.noise_floor
tn = -10 # 默认频谱平滑度
tr = 50 # 默认时间分辨率
else:
params = _LEVEL_PARAMS.get(
self.config.level,
_LEVEL_PARAMS[NoiseReductionLevel.MEDIUM],
)
nf = float(params["nf"])
tn = float(params["tn"])
tr = float(params["tr"])
# 构建 afftdn 滤镜
# nf: noise floor (dB)
# tn: temporal noise floor smoothing (dB)
# tr: time resolution (ms)
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
# 人声增强:通过 highpass + 轻微压缩实现
if self.config.voice_enhance:
# 1. 高通滤波,去除低频噪音
filter_parts.append("highpass=f=80")
# 2. 轻微压缩,提升人声清晰度
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
# 3. 响度归一化
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
filter_str = f"{input_label}{','.join(filter_parts)}{output_label}"
return filter_str
def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str:
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件)。
注意:需要额外下载 RNNNoise 模型文件,默认使用 afftdn(无需额外依赖)。
Args:
input_label: 输入标签
output_label: 输出标签
model_file: RNNNoise 模型文件路径(.rnnn 格式)
Returns:
FFmpeg 滤镜字符串
"""
if not self.config.has_effect():
return f"{input_label}anull{output_label}"
return f"{input_label}arnndn=m={model_file}{output_label}"
def apply_noise_reduction_if_needed(
config_data: dict | None,
input_label: str,
output_label: str,
) -> Optional[str]:
"""便捷函数:根据配置判断是否需要应用音频降噪。
Args:
config_data: 降噪配置字典(从 plan.config.audio_noise_reduction 或 clip.config.noise_reduction 读取)
input_label: 输入标签
output_label: 输出标签
Returns:
滤镜字符串,不需要降噪时返回 None
"""
if not config_data:
return None
try:
config = NoiseReductionConfig.from_dict(config_data)
if not config.has_effect():
return None
engine = NoiseReductionEngine(config)
return engine.build_filter(input_label, output_label)
except Exception as e:
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
return None
+51 -3
View File
@@ -35,6 +35,8 @@ class RenderContext:
work_dir: Path
plan_id: str
# 音频降噪配置(全局,对最终混音结果应用)
noise_reduction_config: dict | None = None
# 音频探测缓存(避免同一 clip 被多次 ffprobe)
_audio_cache: dict[str, bool] = field(default_factory=dict)
@@ -153,12 +155,58 @@ def mix_audio(
try:
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
final_path = mix_bgm_with_main(ctx, output_path, bgm_cfg, video_duration)
return final_path
return _apply_noise_reduction_if_needed(ctx, final_path)
except Exception:
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
return output_path
return _apply_noise_reduction_if_needed(ctx, output_path)
return output_path
return _apply_noise_reduction_if_needed(ctx, output_path)
def _apply_noise_reduction_if_needed(ctx: RenderContext, audio_path: Path) -> Path:
"""如果配置了音频降噪,对已生成的音频文件应用降噪。
作为后处理步骤,对最终混音结果统一降噪。
失败时返回原始文件路径,不阻断主流程。
"""
if not ctx.noise_reduction_config:
return audio_path
try:
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
config = NoiseReductionConfig.from_dict(ctx.noise_reduction_config)
if not config.has_effect():
return audio_path
engine = NoiseReductionEngine(config)
filter_str = engine.build_filter("[0:a]", "[out]")
# 提取滤镜部分(不带标签)
filter_part = filter_str[len("[0:a]") : -len("[out]")]
nr_output_path = audio_path.with_name(f"{audio_path.stem}_nr.aac")
command = [
FFMPEG_BIN,
"-y",
"-i",
str(audio_path),
"-af",
filter_part,
"-acodec",
"aac",
"-b:a",
"128k",
str(nr_output_path),
]
run_ffmpeg(command)
if nr_output_path.exists():
return nr_output_path
logger.warning("[noise-reduction] 降噪输出文件不存在,使用原始音频")
return audio_path
except Exception as e:
logger.warning("[noise-reduction] 音频降噪失败,使用原始音频: %s", e)
return audio_path
def concat_main_audio(
@@ -28,6 +28,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.chroma_key_engine import apply_chroma_key_if_needed
from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine
from video_processing.ffmpeg_utils import (
DEFAULT_FPS,
@@ -333,9 +334,14 @@ class UnifiedRenderService:
"[unified-render] pass-through BGM mix failed, skipping: plan_id=%s", self.plan.id
)
else:
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
config = self.plan.config or {}
bgm_config = config.get("bgm", {}) or {}
noise_reduction_config = config.get("audio_noise_reduction")
ctx = RenderContext(
work_dir=self.work_dir,
plan_id=self.plan.id,
noise_reduction_config=noise_reduction_config,
)
audio_path = mix_audio(
ctx,
layers,
@@ -975,6 +981,18 @@ class UnifiedRenderService:
grade_filter = ColorGradeEngine.build_filter(color_grade)
if grade_filter:
filters.append(grade_filter)
# chroma key 绿幕抠像
try:
from video_processing.chroma_key_engine import ChromaKeyConfig, ChromaKeyEngine
ck_config = ChromaKeyConfig.from_dict(clip.config.get("chroma_key"))
if ck_config.has_effect():
ck_engine = ChromaKeyEngine(ck_config)
ck_full = ck_engine.build_filter("[in]", "[out]")
ck_filter_part = ck_full[len("[in]") : -len("[out]")]
filters.append(ck_filter_part)
except Exception as e:
logger.warning("[unified-render] chroma key 直通模式应用失败,跳过: %s", e)
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
@@ -1015,6 +1033,24 @@ class UnifiedRenderService:
# background 以外的视频素材,默认带音频
has_audio = role != "background"
if has_audio:
# 检查是否需要音频降噪
af_parts: list[str] = []
try:
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
plan_config = getattr(self.plan, "config", {}) or {}
nr_config = NoiseReductionConfig.from_dict(plan_config.get("audio_noise_reduction"))
if nr_config.has_effect():
nr_engine = NoiseReductionEngine(nr_config)
nr_full = nr_engine.build_filter("[in]", "[out]")
nr_filter_part = nr_full[len("[in]") : -len("[out]")]
af_parts.append(nr_filter_part)
except Exception as e:
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
if af_parts:
command.extend(["-af", ",".join(af_parts)])
command.extend(["-c:a", "aac", "-b:a", "128k"])
# 统一截断时长(同时作用于视频和音频)
@@ -1258,6 +1294,19 @@ class UnifiedRenderService:
grade_filter = ColorGradeEngine.build_filter(color_grade)
if grade_filter:
filters.append(grade_filter)
# chroma key 绿幕抠像(在 scale 之后,fps 之前)
try:
from video_processing.chroma_key_engine import ChromaKeyConfig, ChromaKeyEngine
ck_config = ChromaKeyConfig.from_dict(clip.config.get("chroma_key"))
if ck_config.has_effect():
ck_engine = ChromaKeyEngine(ck_config)
# 提取滤镜部分(不带输入输出标签)
ck_full = ck_engine.build_filter("[in]", "[out]")
ck_filter_part = ck_full[len("[in]") : -len("[out]")]
filters.append(ck_filter_part)
except Exception as e:
logger.warning("[unified-render] chroma key 应用失败,跳过 clip=%s: %s", clip.clip_id, e)
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")