Files
xiaoxia-saas/apps/worker/video_processing/bgm_mixer_pure.py
T

352 lines
10 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BGM 混音纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class BGMPureConfig:
"""BGM 混音配置(纯数据类)."""
volume: float = 0.3
fade_in: float = 0.0
fade_out: float = 0.0
loop_enabled: bool = True
sidechain_enabled: bool = False
sidechain_ratio: float = 0.3
sidechain_attack: float = 0.02
sidechain_release: float = 0.5
sidechain_threshold: float = -25.0
# ── 循环判断与计算 ─────────────────────────────────────────────────────────────
def should_loop_bgm(
bgm_duration: float,
target_duration: float,
loop_enabled: bool = True,
) -> bool:
"""判断是否需要循环 BGM.
当 BGM 时长小于目标时长的 90% 时才循环,
避免 BGM 只差一点点就铺满还要循环一次的情况。
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
loop_enabled: 是否允许循环
Returns:
是否需要循环
"""
if not loop_enabled:
return False
if bgm_duration <= 0:
return False
if target_duration <= 0:
return False
return bgm_duration < target_duration * 0.9
def calculate_loop_count(bgm_duration: float, target_duration: float) -> int:
"""计算需要循环的次数.
多算 2 次作为余量,避免末尾因为精度问题不够长。
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
Returns:
循环次数,至少 1
"""
if bgm_duration <= 0:
return 1
if target_duration <= 0:
return 1
if bgm_duration >= target_duration:
return 1
return max(1, int(target_duration / bgm_duration) + 2)
# ── BGM 预处理滤镜链构建 ──────────────────────────────────────────────────────
def build_bgm_filter_chain(
bgm_duration: float,
target_duration: float,
volume: float = 0.3,
fade_in: float = 0.0,
fade_out: float = 0.0,
loop_enabled: bool = True,
) -> str:
"""构建 BGM 预处理滤镜链.
处理顺序:循环 → 音量 → 淡入 → 淡出 → 截断 → 重置时间戳
Args:
bgm_duration: BGM 原始时长(秒)
target_duration: 目标时长(秒)
volume: 音量 0.0~1.0
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
loop_enabled: 是否允许循环
Returns:
FFmpeg filter_complex 字符串(逗号分隔)
"""
# 兜底:目标时长不能为 0 或负数
safe_target = max(5.0, target_duration) if target_duration <= 0 else target_duration
filter_parts: list[str] = []
# 1. 循环
needs_loop = should_loop_bgm(bgm_duration, safe_target, loop_enabled)
if needs_loop:
loop_count = calculate_loop_count(bgm_duration, safe_target)
filter_parts.append(f"aloop=loop={loop_count}:size=0")
# 2. 音量调节(钳制到 0~1
safe_volume = max(0.0, min(1.0, volume))
if abs(safe_volume - 1.0) > 0.001:
filter_parts.append(f"volume={safe_volume:.3f}")
# 3. 淡入
if fade_in > 0:
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
# 4. 淡出(从 target_duration - fade_out 开始)
if fade_out > 0 and safe_target > fade_out:
fade_start = safe_target - fade_out
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
# 5. 截断到目标时长
filter_parts.append(f"atrim=0:{safe_target:.3f}")
# 6. 重置时间戳
filter_parts.append("asetpts=N/SR/TB")
return ",".join(filter_parts)
# ── 混音滤镜构建 ──────────────────────────────────────────────────────────────
def calculate_sidechain_ratio(sidechain_ratio: float) -> float:
"""计算 sidechain 压缩比.
sidechain_ratio 表示闪避时 BGM 音量降低比例(0~1),
映射到 FFmpeg sidechaincompress 的 ratio 参数(2:1 ~ 10:1)。
ratio = 1 / (1 - sidechain_ratio)
Args:
sidechain_ratio: 闪避比例 0.0~1.0
Returns:
FFmpeg ratio 值(2.0 ~ 10.0
"""
if sidechain_ratio <= 0:
return 2.0
if sidechain_ratio >= 1.0:
return 10.0
raw_ratio = 1.0 / (1.0 - sidechain_ratio)
return max(2.0, min(10.0, raw_ratio))
def build_simple_mix_filter() -> str:
"""构建普通 amix 混音滤镜.
两路输入:[0:a] 主音频,[1:a] BGM
主音频权重 1.0,BGM 已在预处理阶段调好音量。
amix 会自动归一化,用 volume=2 补偿衰减。
Returns:
filter_complex 字符串
"""
return "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
def build_sidechain_mix_filter(
threshold: float = -25.0,
ratio: float = 0.3,
attack: float = 0.02,
release: float = 0.5,
) -> str:
"""构建 sidechain 人声闪避混音滤镜.
流程:
1. BGM[1:a] 经过 sidechaincompress,用主音频[0:a]做触发
2. 主音频 + 压缩后的 BGM amix 混音
3. volume=1.5 轻微补偿
Args:
threshold: 触发阈值(dB
ratio: 闪避比例 0.0~1.0(会被转换为 FFmpeg ratio
attack: 攻击时间(秒)
release: 释放时间(秒)
Returns:
filter_complex 字符串
"""
ffmpeg_ratio = calculate_sidechain_ratio(ratio)
return (
f"[1:a][0:a]sidechaincompress="
f"threshold={threshold}dB:"
f"ratio={ffmpeg_ratio:.1f}:"
f"attack={attack:.3f}:"
f"release={release:.3f}:"
f"knee=6[bgm_comp];"
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
f"[outa]volume=1.5[final]"
)
# ── 配置验证与规范化 ──────────────────────────────────────────────────────────
def normalize_bgm_config(config: dict) -> dict:
"""规范化 BGM 配置字典.
将各种类型的输入值转换为正确的类型,
并进行边界钳制。
Args:
config: 原始配置字典
Returns:
规范化后的配置字典
"""
result: dict = {}
# volume: 0.0 ~ 1.0
result["volume"] = max(0.0, min(1.0, float(config.get("volume", 0.3))))
# fade_in: >= 0
result["fade_in"] = max(0.0, float(config.get("fade_in", 0.0)))
# fade_out: >= 0
result["fade_out"] = max(0.0, float(config.get("fade_out", 0.0)))
# loop_enabled: bool
result["loop_enabled"] = bool(config.get("loop_enabled", True))
# sidechain_enabled: bool
result["sidechain_enabled"] = bool(config.get("sidechain_enabled", False))
# sidechain_ratio: 0.0 ~ 1.0
result["sidechain_ratio"] = max(0.0, min(1.0, float(config.get("sidechain_ratio", 0.3))))
# sidechain_attack: > 0
result["sidechain_attack"] = max(0.001, float(config.get("sidechain_attack", 0.02)))
# sidechain_release: > 0
result["sidechain_release"] = max(0.01, float(config.get("sidechain_release", 0.5)))
# sidechain_threshold: dB
result["sidechain_threshold"] = float(config.get("sidechain_threshold", -25.0))
return result
def validate_bgm_config(config: dict) -> tuple[bool, list[str]]:
"""验证 BGM 配置是否合法.
Args:
config: 配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
volume = config.get("volume", 0.3)
if not isinstance(volume, (int, float)):
errors.append("volume 必须是数字")
elif volume < 0 or volume > 1:
errors.append("volume 必须在 0~1 之间")
fade_in = config.get("fade_in", 0)
if not isinstance(fade_in, (int, float)):
errors.append("fade_in 必须是数字")
elif fade_in < 0:
errors.append("fade_in 不能为负数")
fade_out = config.get("fade_out", 0)
if not isinstance(fade_out, (int, float)):
errors.append("fade_out 必须是数字")
elif fade_out < 0:
errors.append("fade_out 不能为负数")
sidechain_ratio = config.get("sidechain_ratio", 0.3)
if not isinstance(sidechain_ratio, (int, float)):
errors.append("sidechain_ratio 必须是数字")
elif sidechain_ratio < 0 or sidechain_ratio > 1:
errors.append("sidechain_ratio 必须在 0~1 之间")
return (len(errors) == 0, errors)
# ── 时长相关工具 ──────────────────────────────────────────────────────────────
def calculate_fade_out_start(
target_duration: float,
fade_out: float,
) -> Optional[float]:
"""计算淡出开始时间.
如果淡出时长大于等于目标时长,返回 None(不做淡出)。
Args:
target_duration: 目标时长(秒)
fade_out: 淡出时长(秒)
Returns:
淡出开始时间(秒),如果不需要淡出返回 None
"""
if fade_out <= 0:
return None
if target_duration <= 0:
return None
if fade_out >= target_duration:
return None
return target_duration - fade_out
def estimate_bgm_processing_duration(
bgm_duration: float,
target_duration: float,
loop_enabled: bool = True,
) -> float:
"""估算 BGM 预处理后的实际输出时长.
正常情况下应该等于 target_duration
但在某些边界情况下可能不同。
Args:
bgm_duration: BGM 原始时长
target_duration: 目标时长
loop_enabled: 是否允许循环
Returns:
预估输出时长(秒)
"""
if target_duration <= 0:
return 5.0 # 兜底时长
# 不需要循环的情况:如果 BGM 够长,截断到 target_duration
if not loop_enabled and bgm_duration >= target_duration:
return target_duration
# 需要循环或 BGM 太短:截断到 target_duration
return target_duration