Files
xiaoxia-saas/apps/worker/video_processing/render_audio.py
T
CI Bot 11a4d83cdb feat: 视频封面生成 + 视频倒放 + 贴纸叠加三个渲染能力
- cover_generator.py: 三种封面生成模式(指定时间抽帧/智能封面/自定义上传)
  - 指定时间点抽帧,默认第1秒,支持时间钳制
  - 智能封面:均匀抽3帧,选最清晰的一帧
  - 自定义封面:自动调整尺寸到标准格式
  - 失败降级:不阻断主流程

- reverse_engine.py: 视频/音频倒放引擎
  - 基于 FFmpeg reverse + areverse 滤镜
  - 每个 clip 独立配置,支持只倒视频或只倒音频
  - 安全限制:超过120s自动跳过,防止内存溢出
  - 配置从 clip.config.reverse 读取,零侵入数据模型

- sticker_engine.py: 贴纸叠加引擎
  - 图片贴纸:位置/大小/透明度/时间范围/淡入淡出
  - 文字贴纸(花字):字体/颜色/描边/阴影/动画
  - 9宫格预设 + 自由坐标(像素/百分比)
  - 多贴纸按 z_index 排序叠加
  - 5个预设贴纸分类
  - 降级:素材不存在自动跳过,不阻断渲染

- UnifiedRenderService 全链路接入
  - 倒放:Step 1 预处理(trim之后scale之前),filter_complex + 直通双路径
  - 贴纸:最终合成后字幕之前叠加,图片贴纸需额外输入
  - 有贴纸时禁用直通模式(需要filter_complex)
  - 音频倒放:render_audio 单clip + 多clip双路径接入

- 59个新增单测,覆盖核心场景 + 降级逻辑 + 集成测试
2026-07-14 10:29:29 +08:00

391 lines
12 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.
"""音频混音模块 — 从 unified_render_service.py 拆分.
职责:
- 主图层音频 concat 拼接
- 独立音频轨 amix 混音
- 音视频合并(mux
所有函数接收 RenderContext 获取共享依赖(work_dir、plan_id 等),
避免直接依赖 UnifiedRenderService 类。
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
# 延迟导入避免循环依赖:unified_render_service 定义 ResolvedClip / RenderLayer
# 本模块提供音频函数供 unified_render_service 调用。
# 使用 from __future__ import annotations + TYPE_CHECKING 解决类型引用。
from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
if TYPE_CHECKING:
from video_processing.unified_render_service import RenderLayer, ResolvedClip
logger = logging.getLogger(__name__)
@dataclass
class RenderContext:
"""渲染上下文 — 提供音频混音所需的共享依赖."""
work_dir: Path
plan_id: str
# 音频探测缓存(避免同一 clip 被多次 ffprobe
_audio_cache: dict[str, bool] = field(default_factory=dict)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长.
与 UnifiedRenderService._clip_effective_duration 逻辑一致。
"""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
"""探测 clip 是否有音频流(带缓存).
避免同一个 clip 被多次 ffprobe 探测。
"""
key = str(clip.local_path)
if key not in ctx._audio_cache:
ctx._audio_cache[key] = probe_has_audio(clip.local_path)
return ctx._audio_cache[key]
# ── 音频混音 ──────────────────────────────────────────────────────────────────
def mix_audio(
ctx: RenderContext,
layers: list[RenderLayer],
video_duration: float,
) -> Path | None:
"""音频后处理混音.
处理逻辑:
1. 主音频源按优先级查找:main > brollbackground 不参与主音频,通常是图片无音轨)
2. 主图层音频按顺序 concat 拼接
3. 独立音频轨(audio role)用 amix 混入
4. 输出时长截断到 video_duration
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
Args:
ctx: 渲染上下文
layers: 图层列表
video_duration: 视频总时长(用于截断音频)
Returns:
混音后的音频文件路径,无音频时返回 None
"""
# 按优先级精确查找主音频图层:main > broll
# background 不参与主音频(通常是静态图片,无音轨)
layer_map = {layer.role: layer for layer in layers}
main_layer = None
for role in ("main", "broll"):
if role in layer_map and layer_map[role].clips:
main_layer = layer_map[role]
break
main_clips: list[ResolvedClip] = main_layer.clips if main_layer else []
# 没有主视频图层时兜底:检查 overlay/corner_voice 层是否有带音频的素材
if not main_clips:
for role in ("overlay", "corner_voice"):
if role in layer_map and layer_map[role].clips:
main_clips = layer_map[role].clips
break
# 收集独立音频轨
audio_clips: list[ResolvedClip] = []
if "audio" in layer_map:
audio_clips = layer_map["audio"].clips
# ── 防御:过滤掉无音频流的 clip ──
main_clips = [c for c in main_clips if clip_has_audio(ctx, c)]
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
if not main_clips and not audio_clips:
return None
# 构建音频处理命令
output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac"
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
if main_clips and not audio_clips:
concat_main_audio(ctx, main_clips, output_path, video_duration)
return output_path
# 有独立音频轨 → amix 混音
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
return output_path
def concat_main_audio(
ctx: RenderContext,
clips: list[ResolvedClip],
output_path: Path,
video_duration: float,
) -> None:
"""主图层音频 concat 拼接(对齐链路A行为).
每个 clip 提取音频 → trim → 按顺序 concat。
"""
if len(clips) == 1:
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
clip = clips[0]
effective_duration = clip_effective_duration(clip)
# 最终时长:取 clip 有效时长和视频总时长的较小值
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
final_duration = effective_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
af_filters = []
if reverse_config.enabled and reverse_config.reverse_audio:
reverse_filter = ReverseEngine.build_audio_filter(
reverse_config, duration=effective_duration
)
if reverse_filter:
af_filters.append(reverse_filter)
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if af_filters:
command.extend(["-af", ",".join(af_filters)])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
return
# 多 clip,用 filter_complex concat
input_args: list[str] = []
filter_parts: list[str] = []
for i, clip in enumerate(clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
audio_filters: list[str] = []
if effective_duration > 0:
audio_filters.append(f"atrim=0:{effective_duration:.3f}")
audio_filters.append("asetpts=PTS-STARTPTS")
else:
audio_filters.append("asetpts=PTS-STARTPTS")
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and reverse_config.reverse_audio:
reverse_filter = ReverseEngine.build_audio_filter(
reverse_config, duration=effective_duration
)
if reverse_filter:
audio_filters.append(reverse_filter)
filter_parts.append(f"[{i}:a]{','.join(audio_filters)}[a{i}]")
audio_labels = "".join(f"[a{i}]" for i in range(len(clips)))
filter_parts.append(f"{audio_labels}concat=n={len(clips)}:v=0:a=1[outa]")
# 截断到视频总时长
if video_duration > 0:
filter_parts.append(f"[outa]atrim=0:{video_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "outa"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
run_ffmpeg(command)
def mix_with_independent_audio(
ctx: RenderContext,
main_clips: list[ResolvedClip],
audio_clips: list[ResolvedClip],
output_path: Path,
video_duration: float,
) -> None:
"""主音频 + 独立音频轨 amix 混音.
Args:
ctx: 渲染上下文
main_clips: 主视频 clips(提取音频后 concat
audio_clips: 独立音频轨 clips
output_path: 输出路径
video_duration: 视频总时长
"""
input_args: list[str] = []
filter_parts: list[str] = []
mix_labels: list[str] = []
input_idx = 0
# 1. 主图层音频 concat
if main_clips:
for clip in main_clips:
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
if effective_duration > 0:
filter_parts.append(
f"[{input_idx}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[ma{input_idx}]"
)
else:
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
input_idx += 1
if len(main_clips) == 1:
mix_labels.append("ma0")
else:
main_labels = "".join(f"[ma{i}]" for i in range(len(main_clips)))
filter_parts.append(f"{main_labels}concat=n={len(main_clips)}:v=0:a=1[main_audio]")
mix_labels.append("main_audio")
# 2. 独立音频轨
for j, clip in enumerate(audio_clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = clip_effective_duration(clip)
volume = clip.config.get("volume", 1.0) if clip.config else 1.0
label = f"ia{j}"
filters = []
if effective_duration > 0:
filters.append(f"atrim=0:{effective_duration:.3f}")
filters.append("asetpts=PTS-STARTPTS")
if volume != 1.0:
filters.append(f"volume={volume}")
filter_parts.append(f"[{input_idx}:a]{','.join(filters)}[{label}]")
mix_labels.append(label)
input_idx += 1
# 3. amix 混音
mix_inputs = "".join(f"[{label}]" for label in mix_labels)
n_inputs = len(mix_labels)
# normalized=0 保持音量,duration=shortest 取最短
filter_parts.append(f"{mix_inputs}amix=inputs={n_inputs}:duration=longest:normalize=0[mixed_audio]")
# 4. 截断到视频时长
if video_duration > 0:
filter_parts.append(f"[mixed_audio]atrim=0:{video_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "mixed_audio"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"音频混音: plan_id=%s main_clips=%d audio_clips=%d",
ctx.plan_id,
len(main_clips),
len(audio_clips),
)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"音频混音失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s",
ctx.plan_id,
e.returncode,
filter_complex[:3000],
)
raise
def merge_audio_video(
ctx: RenderContext,
video_path: Path,
audio_path: Path,
output_path: Path,
) -> None:
"""将音频合并到视频中(视频流拷贝,音频直接复用).
Args:
ctx: 渲染上下文
video_path: 无声视频路径
audio_path: 音频文件路径
output_path: 输出文件路径
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
str(video_path),
"-i",
str(audio_path),
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-shortest",
"-movflags",
"+faststart",
str(output_path),
]
logger.info("合并音视频: plan_id=%s", ctx.plan_id)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"合并音视频失败: plan_id=%s exit_code=%d",
ctx.plan_id,
e.returncode,
)
raise