Files
xiaoxia-saas/apps/worker/video_processing/multi_track_mixer.py
xiaoxia 531aacb57e
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy 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 / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
fix(code-quality): 第二批 - B904 raise-without-from 批量修复 (71个) (#353)
2026-07-15 11:51:45 +08:00

479 lines
16 KiB
Python
Executable File
Raw Permalink 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.
"""多轨道混音引擎 — 支持多路音频独立音量调节与混合.
基于 FFmpeg amix / amerge 实现:
- 支持任意数量音频轨道(原音、BGM、配音、音效等)
- 每轨独立音量调节
- 每轨独立淡入淡出
- 每轨独立时间偏移(delay
- 总输出音量归一化补偿
作为 render_audio.py 的增强模块,在 mix_audio 后处理阶段被调用。
与 bgm_mixer.py 的关系:
- bgm_mixer 专注 BGM 单轨道的复杂处理(循环、人声闪避)
- 本模块专注多路轨道的统一音量调节与混合
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
if TYPE_CHECKING:
from video_processing.render_audio import RenderContext
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
TRACK_TYPE_BGM = "bgm" # 背景音乐
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
TRACK_TYPE_SFX = "sfx" # 音效
TRACK_TYPE_AMBIENT = "ambient" # 环境音
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
# 各轨道默认音量(相对主音频)
DEFAULT_VOLUMES = {
TRACK_TYPE_MAIN: 1.0,
TRACK_TYPE_BGM: 0.3,
TRACK_TYPE_VOICEOVER: 1.0,
TRACK_TYPE_SFX: 0.7,
TRACK_TYPE_AMBIENT: 0.2,
}
@dataclass
class AudioTrack:
"""单条音频轨道配置."""
track_id: str # 轨道唯一标识
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient
audio_path: str # 音频文件路径
volume: float = 1.0 # 音量 0.0 ~ 2.0
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
duration: float = 0.0 # 持续时长(0表示到文件末尾)
enabled: bool = True # 是否启用
@classmethod
def from_dict(cls, track: dict) -> "AudioTrack":
"""从字典创建 AudioTrack,带安全类型转换."""
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
try:
volume = float(track.get("volume", default_vol))
except (TypeError, ValueError):
volume = default_vol
volume = max(0.0, min(2.0, volume))
try:
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
try:
start_time = max(0.0, float(track.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(track.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
track_id=str(track.get("track_id", "")),
track_type=track_type,
audio_path=str(track.get("audio_path", "")),
volume=volume,
fade_in=fade_in,
fade_out=fade_out,
start_time=start_time,
duration=duration,
enabled=bool(track.get("enabled", True)),
)
@dataclass
class MultiTrackMixConfig:
"""多轨道混音配置."""
tracks: list[AudioTrack] = field(default_factory=list)
master_volume: float = 1.0 # 主输出音量
normalize: bool = True # 是否自动归一化补偿
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
@classmethod
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
"""从 plan.config.audio_tracks 字典创建配置."""
if not config or not isinstance(config, dict):
return cls()
tracks_raw = config.get("tracks", [])
tracks: list[AudioTrack] = []
if isinstance(tracks_raw, list):
for t in tracks_raw:
if isinstance(t, dict) and t.get("audio_path"):
try:
track = AudioTrack.from_dict(t)
if track.enabled and track.audio_path:
tracks.append(track)
except Exception:
logger.warning("[multi-track] skip invalid track config: %s", t)
continue
try:
master_volume = float(config.get("master_volume", 1.0))
master_volume = max(0.0, min(2.0, master_volume))
except (TypeError, ValueError):
master_volume = 1.0
return cls(
tracks=tracks,
master_volume=master_volume,
normalize=bool(config.get("normalize", True)),
max_output_volume=float(config.get("max_output_volume", 1.5)),
)
@property
def has_effect(self) -> bool:
"""是否有有效轨道需要混音."""
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
# ── 路径安全校验 ────────────────────────────────────────────────────────────
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
"""校验音频文件路径安全性.
规则:
- local:// schema → 必须在 work_dir 内
- 相对路径 → 必须在 work_dir 内
- 绝对路径 → 必须在允许目录白名单内
- 扩展名必须是音频格式
Raises:
PathSecurityError: 路径不安全
"""
if not audio_path or not isinstance(audio_path, str):
raise PathSecurityError("音频路径不能为空")
# 本地路径(local:// 或相对路径)
if audio_path.startswith("local://") or not audio_path.startswith(("http://", "https://", "oss://")):
is_abs = audio_path.startswith("/") and not audio_path.startswith("local://")
resolved_path = safe_resolve_path(
audio_path,
work_dir,
allow_outside=is_abs,
allowed_extensions=ALLOWED_AUDIO_EXTENSIONS,
)
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
if is_abs:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError as _e:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}") from _e
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
# URL路径,检查扩展名白名单(取 ? 之前的部分)
path_part = audio_path.split("?")[0].split("#")[0]
from pathlib import Path as _P
ext = _P(path_part).suffix.lower()
if ext and ext not in ALLOWED_AUDIO_EXTENSIONS:
raise PathSecurityError(f"不允许的音频文件类型: {ext}")
# ── 单轨道预处理 ────────────────────────────────────────────────────────────
def _prepare_single_track(
ctx: "RenderContext",
track: AudioTrack,
target_duration: float,
output_path: Path,
) -> bool:
"""预处理单条轨道:音量 + 淡入淡出 + 时间偏移 + 截断.
生成一个精确对齐时间轴的音频文件,后续统一 amix 混音。
Returns:
True 表示处理成功,False 表示失败(跳过)
"""
try:
audio_dur = probe_duration(track.audio_path)
except Exception:
logger.warning("[multi-track] probe failed, skip track: %s", track.track_id)
return False
if audio_dur <= 0:
return False
# 计算实际有效时长
effective_start = track.start_time
if track.duration > 0:
effective_dur = min(track.duration, audio_dur)
else:
effective_dur = audio_dur
# 如果轨道完全在视频时长之外,跳过
if effective_start >= target_duration:
return False
if effective_start + effective_dur <= 0:
return False
# 构建滤镜链
filter_parts: list[str] = []
# 1. 先截断到有效范围
trim_start = 0.0 # 从源文件的哪个位置开始取
if effective_start < 0:
trim_start = -effective_start
effective_start = 0.0
# 实际需要的源时长
need_dur = min(effective_dur, target_duration - effective_start)
if need_dur <= 0:
return False
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_dur:.3f}")
filter_parts.append("asetpts=N/SR/TB")
# 2. 音量调节
if abs(track.volume - 1.0) > 0.001:
filter_parts.append(f"volume={track.volume:.3f}")
# 3. 淡入
if track.fade_in > 0 and track.fade_in < need_dur:
filter_parts.append(f"afade=t=in:st=0:d={track.fade_in:.3f}")
# 4. 淡出
if track.fade_out > 0 and track.fade_out < need_dur:
fade_start = need_dur - track.fade_out
if fade_start > 0:
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={track.fade_out:.3f}")
# 5. 时间偏移(用 adelay 实现开头静音填充)
if effective_start > 0.01:
delay_ms = int(effective_start * 1000)
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
# 6. 最终截断到目标总时长
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB")
filter_str = ",".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
"-i",
track.audio_path,
"-filter:a",
filter_str,
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"[multi-track] prepare track: id=%s type=%s vol=%.2f start=%.2f dur=%.2f",
track.track_id,
track.track_type,
track.volume,
effective_start,
need_dur,
)
try:
run_ffmpeg(command)
return True
except Exception as e:
logger.warning("[multi-track] track prepare failed: %s, error=%s", track.track_id, e)
return False
# ── 多轨道混音主入口 ─────────────────────────────────────────────────────────
def mix_multi_track(
ctx: "RenderContext",
main_audio_path: Path,
config: MultiTrackMixConfig,
target_duration: float,
) -> Path:
"""多轨道混音:主音频 + 多条附加轨道.
Args:
ctx: 渲染上下文
main_audio_path: 主音频文件路径(原音)
config: 多轨道混音配置
target_duration: 目标总时长
Returns:
混音后的音频文件路径
"""
output_path = ctx.work_dir / f"multi_track_mix_{ctx.plan_id}.aac"
if target_duration <= 0:
target_duration = 5.0
# ── 安全校验:轨道数量上限 ──
enabled_tracks = [t for t in config.tracks if t.enabled and t.audio_path]
if len(enabled_tracks) > MAX_AUDIO_TRACKS:
logger.warning(
"[multi-track] too many tracks: %d > %d, truncating to max",
len(enabled_tracks),
MAX_AUDIO_TRACKS,
)
enabled_tracks = enabled_tracks[:MAX_AUDIO_TRACKS]
# 更新 config.tracks 为截断后的列表
config.tracks = enabled_tracks
# ── 安全校验:所有音频路径白名单校验 ──
# 主音频路径
try:
_validate_audio_path(str(main_audio_path), ctx.work_dir)
except PathSecurityError as e:
logger.error("[multi-track] main audio path security check failed: %s", e)
raise
# 各轨道音频路径
valid_tracks = []
for track in enabled_tracks:
try:
_validate_audio_path(track.audio_path, ctx.work_dir)
valid_tracks.append(track)
except PathSecurityError as e:
logger.warning("[multi-track] skip track %s: path security check failed: %s", track.track_id, e)
if len(valid_tracks) != len(enabled_tracks):
config.tracks = valid_tracks
logger.info("[multi-track] %d tracks passed security check", len(valid_tracks))
# 收集所有有效轨道(已预处理好的)
prepared_tracks: list[Path] = []
# 主音频作为第0轨
prepared_tracks.append(main_audio_path)
# 预处理每条附加轨道
for i, track in enumerate(config.tracks):
if not track.enabled or not track.audio_path:
continue
track_out = ctx.work_dir / f"track_{i}_{ctx.plan_id}.aac"
if _prepare_single_track(ctx, track, target_duration, track_out):
prepared_tracks.append(track_out)
# 如果只有主音频,直接返回(无需混音)
if len(prepared_tracks) <= 1:
import shutil
shutil.copy2(main_audio_path, output_path)
return output_path
# 使用 amix 混音
num_inputs = len(prepared_tracks)
# 构建输入参数
input_args: list[str] = []
for tp in prepared_tracks:
input_args.extend(["-i", str(tp)])
# amix 的 duration=first 以第一个输入(主音频)时长为准
# normalize 补偿:amix 会把每路音量除以 N,需要乘回来
# 但如果所有轨道都同时有声,可能会爆音,所以用 master_volume 控制
if config.normalize:
# 经验值:不是所有轨道都同时有声,补偿系数取 N * 0.7
compensate = num_inputs * 0.7
else:
compensate = 1.0
final_volume = compensate * config.master_volume
final_volume = min(final_volume, config.max_output_volume)
# 构建 filter_complex
inputs_label = "".join(f"[{i}:a]" for i in range(num_inputs))
filter_complex = (
f"{inputs_label}amix=inputs={num_inputs}:duration=first:dropout_transition=0[outa];"
f"[outa]volume={final_volume:.3f}[final]"
)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[final]",
"-c:a",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"[multi-track] mix %d tracks, master_vol=%.2f compensate=%.2f final_vol=%.2f",
num_inputs,
config.master_volume,
compensate,
final_volume,
)
try:
run_ffmpeg(command)
except Exception as e:
logger.error("[multi-track] mix failed, fallback to main audio only: %s", e)
import shutil
shutil.copy2(main_audio_path, output_path)
return output_path
# ── 便捷函数:从 plan.config 快速混音 ───────────────────────────────────────
def mix_audio_tracks_from_config(
ctx: "RenderContext",
main_audio_path: Path,
audio_tracks_config: dict | None,
target_duration: float,
) -> Path:
"""从 plan.config.audio_tracks 配置执行多轨道混音.
降级策略:配置无效或混音失败时返回主音频。
"""
config = MultiTrackMixConfig.from_config_dict(audio_tracks_config)
if not config.has_effect:
return main_audio_path
return mix_multi_track(ctx, main_audio_path, config, target_duration)