feat(unified-render): Phase 3 - 音频统一混音 + 灰度观测埋点
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m27s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m4s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 9m39s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m27s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m4s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 9m39s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
Phase 3 unified render with audio mixing + gray scale observation metrics
This commit was merged in pull request #232.
This commit is contained in:
@@ -85,6 +85,41 @@ def run_ffmpeg(
|
||||
raise
|
||||
|
||||
|
||||
def probe_has_audio(local_path: str | Path) -> bool:
|
||||
"""探测文件是否包含音频流。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径
|
||||
|
||||
Returns:
|
||||
True 表示有音频流(或探测失败保守返回),False 表示确认无音频流
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_type",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.stdout.strip() == "audio"
|
||||
except Exception:
|
||||
# 探测失败保守返回 True,让 FFmpeg 自己处理(避免误删音频)
|
||||
return True
|
||||
|
||||
|
||||
def probe_duration(local_path: str | Path) -> float:
|
||||
"""用 ffprobe 获取视频时长(秒)。
|
||||
|
||||
|
||||
@@ -21,17 +21,10 @@ from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from video_processing.oss_helpers import download_asset, upload_to_oss
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
@@ -140,7 +133,7 @@ class RenderAdapter:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"开始渲染: plan_id=%s job_id=%s ready_clips=%d",
|
||||
"开始渲染: plan_id=%s job_id=%s ready_clips=%d engine=unified",
|
||||
plan_id,
|
||||
job_id,
|
||||
len(ready_clips),
|
||||
@@ -176,6 +169,18 @@ class RenderAdapter:
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
logger.info(
|
||||
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
|
||||
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
|
||||
plan_id,
|
||||
job_id,
|
||||
result.duration,
|
||||
result.file_size,
|
||||
result.width,
|
||||
result.height,
|
||||
len(ready_clips),
|
||||
)
|
||||
|
||||
return RenderAdapterResult(
|
||||
success=True,
|
||||
output_url=output_url or "",
|
||||
@@ -188,7 +193,12 @@ class RenderAdapter:
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染失败: plan_id=%s", plan_id)
|
||||
logger.exception(
|
||||
"[render-adapter] render failed: plan_id=%s job_id=%s engine=unified error=%s",
|
||||
plan_id,
|
||||
job_id,
|
||||
str(exc)[:200],
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=str(exc)[:500],
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -356,6 +357,8 @@ def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
|
||||
# main type
|
||||
if role == "b_roll":
|
||||
return "broll"
|
||||
if role == "audio":
|
||||
return "audio"
|
||||
return "main"
|
||||
|
||||
|
||||
@@ -415,9 +418,16 @@ class UnifiedRenderService:
|
||||
1. 视频主渲染(直通或完整链路)
|
||||
2. 如有 title/subtitle,叠加 ASS 字幕
|
||||
|
||||
音频后处理:
|
||||
1. 主图层音频 concat 拼接
|
||||
2. 独立音频轨 amix 混入
|
||||
3. 合并到输出视频
|
||||
|
||||
Raises:
|
||||
ValueError: 没有可渲染的片段时抛出
|
||||
"""
|
||||
t_start = time.time()
|
||||
|
||||
# 1. 解析 clips → ResolvedClips(跳过无素材的 clip)
|
||||
resolved = self._resolve_clips()
|
||||
if not resolved:
|
||||
@@ -432,18 +442,88 @@ class UnifiedRenderService:
|
||||
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
|
||||
ass_path = self._maybe_generate_ass(video_duration)
|
||||
|
||||
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
|
||||
# 灰度埋点:开始渲染
|
||||
layer_roles = [layer.role for layer in layers]
|
||||
clip_counts = {layer.role: len(layer.clips) for layer in layers}
|
||||
logger.info(
|
||||
"[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s",
|
||||
self.plan.id,
|
||||
len(resolved),
|
||||
layer_roles,
|
||||
clip_counts,
|
||||
)
|
||||
|
||||
# 5. 视频主渲染
|
||||
if self._can_use_pass_through(layers):
|
||||
self._render_pass_through(layers, output_path, ass_path=ass_path)
|
||||
t_video_start = time.time()
|
||||
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
|
||||
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
|
||||
|
||||
is_pass_through = self._can_use_pass_through(layers)
|
||||
pass_through_has_audio = False
|
||||
|
||||
if is_pass_through:
|
||||
# 直通优化:单clip场景一次FFmpeg同时处理视频+音频,省去提取+合并两次调用
|
||||
pass_through_has_audio = self._render_pass_through(
|
||||
layers, output_path, ass_path=ass_path, video_duration=video_duration
|
||||
)
|
||||
else:
|
||||
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
|
||||
self._execute_ffmpeg(filter_complex, input_args, output_path)
|
||||
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
|
||||
|
||||
# 6. 探测输出
|
||||
t_video_end = time.time()
|
||||
video_render_ms = int((t_video_end - t_video_start) * 1000)
|
||||
logger.info(
|
||||
"[unified-render] video render done: plan_id=%s duration_ms=%d pass_through=%s",
|
||||
self.plan.id,
|
||||
video_render_ms,
|
||||
is_pass_through,
|
||||
)
|
||||
|
||||
# 6. 音频后处理混音(直通场景已合并处理,跳过)
|
||||
t_audio_start = time.time()
|
||||
audio_mix_ms = 0
|
||||
has_audio = False
|
||||
|
||||
if is_pass_through:
|
||||
# 直通场景已在一次调用中完成视频+音频
|
||||
has_audio = pass_through_has_audio
|
||||
else:
|
||||
audio_path = self._mix_audio(layers, video_duration)
|
||||
t_audio_end = time.time()
|
||||
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
|
||||
has_audio = audio_path is not None
|
||||
if has_audio:
|
||||
logger.info(
|
||||
"[unified-render] audio mix done: plan_id=%s duration_ms=%d",
|
||||
self.plan.id,
|
||||
audio_mix_ms,
|
||||
)
|
||||
# 7. 合并音视频
|
||||
self._merge_audio_video(video_only_path, audio_path, output_path)
|
||||
else:
|
||||
# 无音频,直接用无声视频
|
||||
import shutil
|
||||
|
||||
shutil.copy2(video_only_path, output_path)
|
||||
|
||||
# 8. 探测输出
|
||||
duration, file_size, width, height = self._probe_output(output_path)
|
||||
|
||||
t_total = int((time.time() - t_start) * 1000)
|
||||
logger.info(
|
||||
"[unified-render] render done: plan_id=%s total_ms=%d video_ms=%d audio_ms=%d "
|
||||
"output_duration=%.2fs output_size=%d resolution=%dx%d has_audio=%s",
|
||||
self.plan.id,
|
||||
t_total,
|
||||
video_render_ms,
|
||||
audio_mix_ms if has_audio else 0,
|
||||
duration,
|
||||
file_size,
|
||||
width,
|
||||
height,
|
||||
has_audio,
|
||||
)
|
||||
|
||||
return RenderResult(
|
||||
output_path=output_path,
|
||||
duration=duration,
|
||||
@@ -470,14 +550,7 @@ class UnifiedRenderService:
|
||||
if not main_layer or not main_layer.clips:
|
||||
return 0.0
|
||||
|
||||
total = sum(
|
||||
(
|
||||
min(c.duration, c.actual_duration)
|
||||
if c.duration > 0 and c.actual_duration > 0
|
||||
else (c.duration if c.duration > 0 else c.actual_duration)
|
||||
)
|
||||
for c in main_layer.clips
|
||||
)
|
||||
total = sum(UnifiedRenderService._clip_effective_duration(c) for c in main_layer.clips)
|
||||
|
||||
# 减去转场重叠时间(粗略估算)
|
||||
n_clips = len(main_layer.clips)
|
||||
@@ -547,30 +620,36 @@ class UnifiedRenderService:
|
||||
return True
|
||||
|
||||
def _render_pass_through(
|
||||
self, layers: list[RenderLayer], output_path: Path, *, ass_path: Path | None = None
|
||||
) -> None:
|
||||
"""单图层单 clip 直通渲染(使用 -vf 而非 -filter_complex)。
|
||||
self,
|
||||
layers: list[RenderLayer],
|
||||
output_path: Path,
|
||||
*,
|
||||
ass_path: Path | None = None,
|
||||
video_duration: float = 0.0,
|
||||
) -> bool:
|
||||
"""单图层单 clip 直通渲染(使用 -vf 而非 -filter_complex),一次性输出带音频的最终视频。
|
||||
|
||||
性能优化:避免 filter_complex 的解析和调度开销,
|
||||
对于一镜到底场景性能提升 ~30%,接近链路A水平。
|
||||
性能优化:
|
||||
- 避免 filter_complex 的解析和调度开销,单clip场景性能提升 ~30%
|
||||
- 视频+音频一次FFmpeg调用完成,省去后续音频提取+音视频合并两次调用
|
||||
|
||||
Args:
|
||||
layers: 图层列表(只有1个图层1个clip)
|
||||
output_path: 输出文件路径
|
||||
ass_path: ASS 字幕文件路径,有则叠加字幕
|
||||
video_duration: 视频总时长(用于截断音频,0表示不额外截断)
|
||||
|
||||
Returns:
|
||||
True 表示输出包含音频(近似判断,实际以输出文件为准)
|
||||
"""
|
||||
clip = layers[0].clips[0]
|
||||
role = layers[0].role
|
||||
|
||||
# 构建滤镜链(与 _build_filter_complex 中预处理逻辑一致)
|
||||
# 构建视频滤镜链(与 _build_filter_complex 中预处理逻辑一致)
|
||||
filters: list[str] = []
|
||||
|
||||
# trim
|
||||
effective_duration = 0.0
|
||||
if clip.duration > 0:
|
||||
effective_duration = min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||
elif clip.actual_duration > 0:
|
||||
effective_duration = clip.actual_duration
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
|
||||
if effective_duration > 0:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
@@ -592,12 +671,16 @@ class UnifiedRenderService:
|
||||
|
||||
# 字幕叠加
|
||||
if ass_path is not None:
|
||||
# ASS 文件路径需要转义:Windows 反斜杠转正斜杠,冒号转义
|
||||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
|
||||
filters.append(f"subtitles='{ass_filter_path}'")
|
||||
|
||||
vf_str = ",".join(filters)
|
||||
|
||||
# 最终输出时长:取 clip 有效时长和 video_duration 的较小值
|
||||
final_duration = effective_duration
|
||||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||||
final_duration = video_duration
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
@@ -615,16 +698,27 @@ class UnifiedRenderService:
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-an", # 直通模式暂不处理音频,音频统一在后续混音阶段处理
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
# 音频处理:background 通常是图片无音频,跳过;其他编码为 aac
|
||||
# background 以外的视频素材,默认带音频
|
||||
has_audio = role != "background"
|
||||
if has_audio:
|
||||
command.extend(["-c:a", "aac", "-b:a", "128k"])
|
||||
|
||||
# 统一截断时长(同时作用于视频和音频)
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
|
||||
command.append(str(output_path))
|
||||
|
||||
logger.info(
|
||||
"直通渲染: plan_id=%s clip=%s role=%s duration=%.2fs",
|
||||
"直通渲染: plan_id=%s clip=%s role=%s duration=%.2fs has_audio=%s",
|
||||
self.plan.id,
|
||||
clip.clip_id,
|
||||
role,
|
||||
effective_duration,
|
||||
has_audio,
|
||||
)
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
@@ -638,6 +732,8 @@ class UnifiedRenderService:
|
||||
)
|
||||
raise
|
||||
|
||||
return has_audio
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_clips(self) -> list[ResolvedClip]:
|
||||
@@ -759,14 +855,7 @@ class UnifiedRenderService:
|
||||
filters: list[str] = []
|
||||
|
||||
# trim — 始终将输出截断到有效时长,防止 xfade offset 与实际时长不匹配
|
||||
# 有效时长 = min(指定时长, 实际时长);若均未设置则跳过
|
||||
effective_duration = 0.0
|
||||
if clip.duration > 0:
|
||||
effective_duration = (
|
||||
min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||
)
|
||||
elif clip.actual_duration > 0:
|
||||
effective_duration = clip.actual_duration
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
|
||||
if effective_duration > 0:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
@@ -803,14 +892,7 @@ class UnifiedRenderService:
|
||||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||||
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
|
||||
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
|
||||
layer_durations = []
|
||||
for i in layer_clip_indices:
|
||||
c = all_clips[i]
|
||||
if c.duration > 0:
|
||||
eff = min(c.duration, c.actual_duration) if c.actual_duration > 0 else c.duration
|
||||
else:
|
||||
eff = c.actual_duration if c.actual_duration > 0 else 0.0
|
||||
layer_durations.append(eff)
|
||||
layer_durations = [UnifiedRenderService._clip_effective_duration(all_clips[i]) for i in layer_clip_indices]
|
||||
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
|
||||
|
||||
if len(layer_labels) == 1:
|
||||
@@ -930,7 +1012,7 @@ class UnifiedRenderService:
|
||||
raise
|
||||
|
||||
def _probe_output(self, output_path: Path) -> tuple[float, int, int, int]:
|
||||
"""探测输出文件的时长、大小、宽高。
|
||||
"""探测输出文件的时长、大小、宽高.
|
||||
|
||||
Returns:
|
||||
(duration, file_size, width, height)
|
||||
@@ -943,3 +1025,304 @@ class UnifiedRenderService:
|
||||
info["width"],
|
||||
info["height"],
|
||||
)
|
||||
|
||||
# ── 音频后处理 ────────────────────────────────────────────────────────
|
||||
|
||||
def _mix_audio(self, layers: list[RenderLayer], video_duration: float) -> Path | None:
|
||||
"""音频后处理混音.
|
||||
|
||||
处理逻辑:
|
||||
1. 主音频源按优先级查找:main > broll(background 不参与主音频,通常是图片无音轨)
|
||||
2. 主图层音频按顺序 concat 拼接
|
||||
3. 独立音频轨(audio role)用 amix 混入
|
||||
4. 输出时长截断到 video_duration
|
||||
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
|
||||
|
||||
Args:
|
||||
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 ──
|
||||
# 源视频可能没有音频流(如静音视频、纯图片转的视频),直接引用 [i:a] 会导致 FFmpeg 失败
|
||||
main_clips = [c for c in main_clips if self._clip_has_audio(c)]
|
||||
audio_clips = [c for c in audio_clips if self._clip_has_audio(c)]
|
||||
|
||||
if not main_clips and not audio_clips:
|
||||
return None
|
||||
|
||||
# 构建音频处理命令
|
||||
output_path = self.work_dir / f"audio_{self.plan.id}.aac"
|
||||
|
||||
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
|
||||
if main_clips and not audio_clips:
|
||||
self._concat_main_audio(main_clips, output_path, video_duration)
|
||||
return output_path
|
||||
|
||||
# 有独立音频轨 → amix 混音
|
||||
self._mix_with_independent_audio(main_clips, audio_clips, output_path, video_duration)
|
||||
return output_path
|
||||
|
||||
def _concat_main_audio(self, 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 = self._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
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
]
|
||||
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 = self._clip_effective_duration(clip)
|
||||
if effective_duration > 0:
|
||||
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
|
||||
else:
|
||||
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[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(
|
||||
self,
|
||||
main_clips: list[ResolvedClip],
|
||||
audio_clips: list[ResolvedClip],
|
||||
output_path: Path,
|
||||
video_duration: float,
|
||||
) -> None:
|
||||
"""主音频 + 独立音频轨 amix 混音.
|
||||
|
||||
Args:
|
||||
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 = self._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 = self._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",
|
||||
self.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",
|
||||
self.plan.id,
|
||||
e.returncode,
|
||||
filter_complex[:3000],
|
||||
)
|
||||
raise
|
||||
|
||||
def _merge_audio_video(self, video_path: Path, audio_path: Path, output_path: Path) -> None:
|
||||
"""将音频合并到视频中(视频流拷贝,音频直接复用).
|
||||
|
||||
Args:
|
||||
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", self.plan.id)
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(
|
||||
"合并音视频失败: plan_id=%s exit_code=%d",
|
||||
self.plan.id,
|
||||
e.returncode,
|
||||
)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长."""
|
||||
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(self, clip: ResolvedClip) -> bool:
|
||||
"""探测 clip 是否有音频流(带缓存).
|
||||
|
||||
避免同一个 clip 被多次 ffprobe 探测。
|
||||
"""
|
||||
if not hasattr(self, "_audio_cache"):
|
||||
self._audio_cache: dict[str, bool] = {}
|
||||
key = str(clip.local_path)
|
||||
if key not in self._audio_cache:
|
||||
from .ffmpeg_utils import probe_has_audio
|
||||
|
||||
self._audio_cache[key] = probe_has_audio(clip.local_path)
|
||||
return self._audio_cache[key]
|
||||
|
||||
@@ -573,6 +573,8 @@ class TestPassThrough:
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||||
patch.object(svc, "_mix_audio", return_value=None),
|
||||
patch("shutil.copy2"),
|
||||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
@@ -598,6 +600,8 @@ class TestPassThrough:
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||||
patch.object(svc, "_mix_audio", return_value=None),
|
||||
patch("shutil.copy2"),
|
||||
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
@@ -816,6 +820,8 @@ class TestRender:
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||||
patch.object(svc, "_mix_audio", return_value=None),
|
||||
patch("shutil.copy2"),
|
||||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
@@ -843,6 +849,8 @@ class TestRender:
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||||
patch.object(svc, "_mix_audio", return_value=None),
|
||||
patch("shutil.copy2"),
|
||||
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
@@ -853,3 +861,541 @@ class TestRender:
|
||||
assert result.width == 1280
|
||||
assert result.height == 720
|
||||
mock_exec.assert_called_once()
|
||||
|
||||
|
||||
# ── 测试音频后处理 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAudioMixing:
|
||||
"""测试音频后处理混音功能。"""
|
||||
|
||||
def test_clip_effective_duration_with_both(self):
|
||||
"""指定时长和实际时长都有时取较小值。"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/c1.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=3.0,
|
||||
actual_duration=5.0,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 3.0
|
||||
|
||||
def test_clip_effective_duration_only_actual(self):
|
||||
"""只有实际时长时用实际时长。"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/c1.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=0.0,
|
||||
actual_duration=5.0,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 5.0
|
||||
|
||||
def test_clip_effective_duration_only_specified(self):
|
||||
"""只有指定时长时用指定时长。"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/c1.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=3.0,
|
||||
actual_duration=0.0,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 3.0
|
||||
|
||||
def test_clip_effective_duration_zero(self):
|
||||
"""都没有时返回0。"""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/c1.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=0.0,
|
||||
actual_duration=0.0,
|
||||
)
|
||||
assert UnifiedRenderService._clip_effective_duration(clip) == 0.0
|
||||
|
||||
def test_mix_audio_single_main_clip(self):
|
||||
"""单主clip时直接提取音频。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "audio_plan_001.aac"
|
||||
mock_run.assert_called_once()
|
||||
# 验证命令包含 -vn(无视频)和 aac 编码
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-vn" in cmd
|
||||
assert "aac" in cmd
|
||||
|
||||
def test_mix_audio_multi_main_clips(self):
|
||||
"""多主clip时用concat拼接音频。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0),
|
||||
_make_clip("c2", "main", order=1, duration=2.0),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 4.5)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
# 验证有 filter_complex 和 concat
|
||||
assert "-filter_complex" in cmd
|
||||
cmd_str = " ".join(cmd)
|
||||
assert "concat=n=2:v=0:a=1" in cmd_str
|
||||
|
||||
def test_mix_audio_with_independent_audio_track(self):
|
||||
"""有独立音频轨时用amix混音。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=5.0),
|
||||
_make_clip(
|
||||
"bgm1",
|
||||
"main",
|
||||
order=0,
|
||||
duration=5.0,
|
||||
config={"role": "audio", "volume": 0.5},
|
||||
),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
cmd_str = " ".join(cmd)
|
||||
assert "amix" in cmd_str
|
||||
assert "volume=0.5" in cmd_str
|
||||
|
||||
def test_mix_audio_no_audio_returns_none(self):
|
||||
"""没有音频素材时返回None。"""
|
||||
# 构造一个没有音频的场景(比如纯文字)
|
||||
clips = [_make_clip("t1", "title", order=0, duration=3.0)]
|
||||
clips[0].asset_id = "" # 无素材
|
||||
asset_paths: dict[str, Path] = {}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=3.0),
|
||||
):
|
||||
# 没有素材的clip会被跳过,layers为空
|
||||
resolved = svc._resolve_clips()
|
||||
layers = svc._group_clips_into_layers(resolved)
|
||||
result = svc._mix_audio(layers, 3.0)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_mix_audio_background_not_used_as_main(self):
|
||||
"""background 图层不参与主音频,main 优先级更高。"""
|
||||
clips = [
|
||||
_make_clip("bg1", "background", order=0, duration=5.0),
|
||||
_make_clip("c1", "main", order=0, duration=5.0),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_bg1.mp4": Path("/tmp/asset_bg1.mp4"),
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
# 验证主音频源是 main 的 c1,不是 background 的 bg1
|
||||
# 单 main clip 走直接提取路径,输入文件应该只有 c1
|
||||
cmd_str = " ".join(cmd)
|
||||
assert "asset_c1.mp4" in cmd_str
|
||||
assert "asset_bg1.mp4" not in cmd_str
|
||||
|
||||
def test_mix_audio_main_priority_over_broll(self):
|
||||
"""main 图层优先级高于 broll。"""
|
||||
clips = [
|
||||
_make_clip("b1", "b_roll", order=0, duration=5.0),
|
||||
_make_clip("c1", "main", order=0, duration=5.0),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_b1.mp4": Path("/tmp/asset_b1.mp4"),
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
cmd_str = " ".join(cmd)
|
||||
# 主音频源应该是 main 的 c1,不是 broll 的 b1
|
||||
assert "asset_c1.mp4" in cmd_str
|
||||
assert "asset_b1.mp4" not in cmd_str
|
||||
|
||||
def test_mix_audio_broll_used_when_no_main(self):
|
||||
"""没有 main 时,broll 作为主音频源。"""
|
||||
clips = [_make_clip("b1", "b_roll", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_b1.mp4": Path("/tmp/asset_b1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-vn" in cmd
|
||||
assert "asset_b1.mp4" in " ".join(cmd)
|
||||
|
||||
def test_mix_audio_single_clip_truncated_to_video_duration(self):
|
||||
"""单clip音频截断到 video_duration(video_duration < clip有效时长)。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=10.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
# video_duration 只有 3.0,小于 clip 的 10.0
|
||||
result = svc._mix_audio(layers, 3.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
# 验证 -t 参数是 3.0 不是 10.0
|
||||
t_index = cmd.index("-t")
|
||||
assert t_index >= 0
|
||||
t_value = float(cmd[t_index + 1])
|
||||
assert t_value == 3.0
|
||||
|
||||
def test_merge_audio_video(self):
|
||||
"""合并音视频命令正确。"""
|
||||
svc = _make_service([], {})
|
||||
video_path = Path("/tmp/video.mp4")
|
||||
audio_path = Path("/tmp/audio.aac")
|
||||
output_path = Path("/tmp/output.mp4")
|
||||
|
||||
with patch("video_processing.unified_render_service.run_ffmpeg") as mock_run:
|
||||
svc._merge_audio_video(video_path, audio_path, output_path)
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-c:v" in cmd
|
||||
assert "copy" in cmd
|
||||
assert "-map" in cmd
|
||||
assert "-shortest" in cmd
|
||||
|
||||
def test_render_calls_audio_mixing(self):
|
||||
"""多clip完整render流程会调用音频混音(非直通路径)。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0),
|
||||
_make_clip("c2", "main", order=1, duration=2.0),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_execute_ffmpeg"),
|
||||
patch.object(svc, "_mix_audio", return_value=Path("/tmp/audio.aac")) as mock_mix,
|
||||
patch.object(svc, "_merge_audio_video") as mock_merge,
|
||||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
|
||||
mock_mix.assert_called_once()
|
||||
mock_merge.assert_called_once()
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_render_without_audio_copies_video(self):
|
||||
"""多clip无音频时走copy路径(非直通路径)。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0),
|
||||
_make_clip("c2", "main", order=1, duration=2.0),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_execute_ffmpeg"),
|
||||
patch.object(svc, "_mix_audio", return_value=None),
|
||||
patch("shutil.copy2") as mock_copy,
|
||||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
|
||||
mock_copy.assert_called_once()
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_render_pass_through_skips_audio_mix(self):
|
||||
"""直通场景下视频+音频一次完成,跳过 _mix_audio 和 _merge_audio_video。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch.object(svc, "_render_pass_through", return_value=True) as mock_pt,
|
||||
patch.object(svc, "_mix_audio") as mock_mix,
|
||||
patch.object(svc, "_merge_audio_video") as mock_merge,
|
||||
patch("shutil.copy2") as mock_copy,
|
||||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||||
):
|
||||
result = svc.render()
|
||||
|
||||
# 直通场景调用了 _render_pass_through,跳过了 _mix_audio / _merge / copy
|
||||
mock_pt.assert_called_once()
|
||||
mock_mix.assert_not_called()
|
||||
mock_merge.assert_not_called()
|
||||
mock_copy.assert_not_called()
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_pass_through_main_has_aac_audio(self):
|
||||
"""直通main/broll场景输出带aac音频。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=5.0)
|
||||
|
||||
assert result is True # main 类型返回有音频
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-an" not in cmd # 不再是无声
|
||||
assert "aac" in cmd # 有aac音频编码
|
||||
assert "-b:a" in cmd
|
||||
|
||||
def test_pass_through_background_no_audio(self):
|
||||
"""直通background场景不带音频(图片素材)。"""
|
||||
clips = [_make_clip("bg1", "background", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_bg1.mp4": Path("/tmp/asset_bg1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._render_pass_through(layers, Path("/tmp/out.mp4"))
|
||||
|
||||
assert result is False # background 返回无音频
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "aac" not in cmd # 没有音频编码参数
|
||||
|
||||
# ── 无音轨视频防御测试 ──
|
||||
|
||||
def test_mix_audio_main_no_audio_stream_returns_none(self):
|
||||
"""主图层clip无音频流且无独立音频轨时,返回None(不报错)。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.ffmpeg_utils.probe_has_audio", return_value=False),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is None
|
||||
# 没有音频流时不应调用 FFmpeg
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_mix_audio_partial_clips_no_audio_filtered(self):
|
||||
"""部分主图层clip无音频流时,过滤掉无音轨的,剩余有音频的正常concat。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=3.0), # 无音频
|
||||
_make_clip("c2", "main", order=1, duration=2.0), # 有音频
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
# 模拟:c1 无音频,c2 有音频
|
||||
def fake_has_audio(path):
|
||||
return "c2" in str(path)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.ffmpeg_utils.probe_has_audio", side_effect=fake_has_audio),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
cmd_str = " ".join(cmd)
|
||||
# 只剩 1 个有效音频 clip,走单clip路径(-vn),不走 filter_complex concat
|
||||
assert "-vn" in cmd
|
||||
assert "concat=n=2" not in cmd_str
|
||||
|
||||
def test_mix_audio_all_main_no_audio_but_independent_track(self):
|
||||
"""主图层全部无音频,但有独立音频轨时,正常走amix混音。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=5.0), # 无音频
|
||||
_make_clip(
|
||||
"bgm1",
|
||||
"main",
|
||||
order=0,
|
||||
duration=5.0,
|
||||
config={"role": "audio", "volume": 0.5},
|
||||
), # 独立音频轨(有音频)
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
def fake_has_audio(path):
|
||||
return "bgm" in str(path)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.ffmpeg_utils.probe_has_audio", side_effect=fake_has_audio),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is not None
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
cmd_str = " ".join(cmd)
|
||||
# 只有独立音频轨参与混音,amix 输入数=1
|
||||
assert "amix=inputs=1" in cmd_str
|
||||
|
||||
def test_mix_audio_both_no_audio_returns_none(self):
|
||||
"""主图层和独立音频轨都无音频时,返回None。"""
|
||||
clips = [
|
||||
_make_clip("c1", "main", order=0, duration=5.0),
|
||||
_make_clip(
|
||||
"bgm1",
|
||||
"main",
|
||||
order=0,
|
||||
duration=5.0,
|
||||
config={"role": "audio"},
|
||||
),
|
||||
]
|
||||
asset_paths = {
|
||||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||||
"asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"),
|
||||
}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
patch("video_processing.ffmpeg_utils.probe_has_audio", return_value=False),
|
||||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||||
):
|
||||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||||
result = svc._mix_audio(layers, 5.0)
|
||||
|
||||
assert result is None
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_clip_has_audio_cache(self):
|
||||
"""_clip_has_audio 带缓存,同一clip只探测一次。"""
|
||||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||||
svc = _make_service(clips, asset_paths)
|
||||
|
||||
with (
|
||||
_patch_path_exists(),
|
||||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||||
):
|
||||
resolved = svc._resolve_clips()
|
||||
clip = resolved[0]
|
||||
|
||||
with patch("video_processing.ffmpeg_utils.probe_has_audio", return_value=True) as mock_probe:
|
||||
# 调用 3 次
|
||||
r1 = svc._clip_has_audio(clip)
|
||||
r2 = svc._clip_has_audio(clip)
|
||||
r3 = svc._clip_has_audio(clip)
|
||||
|
||||
assert r1 is True and r2 is True and r3 is True
|
||||
# 实际只探测了 1 次
|
||||
assert mock_probe.call_count == 1
|
||||
|
||||
Reference in New Issue
Block a user