Files
xiaoxia-saas/tests/unit/test_unified_render_service.py
T
CI Bot 6f0a8253f6
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m41s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
fix(P1-1/P1-2): processor.py 补 logger 定义 + 测试文件导入路径同步到拆分后模块
P1-1: processor.py 使用了 logger 但未定义,补 import logging + getLogger
P1-2: test_unified_render_service.py 导入路径同步:
  - 音频函数 (mix_audio/merge_audio_video/clip_has_audio/RenderContext) → render_audio
  - 字幕函数 (_hex_to_ass_color/_position_to_ass_alignment/generate_ass_subtitles) → render_subtitles
  - patch.object(svc, '_mix_audio') → patch('video_processing.unified_render_service.mix_audio')
  - patch.object(svc, '_merge_audio_video') → patch('video_processing.unified_render_service.merge_audio_video')
  - probe_has_audio/run_ffmpeg patch 路径对齐到 render_audio 模块

全部 84 个测试通过。
2026-07-13 21:06:26 +08:00

1663 lines
64 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.
"""UnifiedRenderService 单元测试.
测试图层分组算法、filter_complex 构建、以及渲染流程。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from video_processing.render_audio import (
RenderContext,
clip_has_audio,
merge_audio_video,
mix_audio,
)
from video_processing.render_subtitles import (
_hex_to_ass_color,
_position_to_ass_alignment,
generate_ass_subtitles,
)
from video_processing.unified_render_service import (
RenderResult,
ResolvedClip,
UnifiedRenderService,
_resolve_layer_role,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@dataclass
class FakeClip:
"""模拟 EditPlanClip。"""
id: str
plan_id: str = "plan_001"
clip_type: str = "main"
order: int = 0
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
status: str = "ready"
config: dict[str, Any] = field(default_factory=dict)
@dataclass
class FakePlan:
"""模拟 EditPlan。"""
id: str = "plan_001"
name: str = "测试计划"
config: dict[str, Any] = field(default_factory=dict)
def _make_clip(
clip_id: str,
clip_type: str = "main",
order: int = 0,
asset_id: str = "",
duration: float = 0.0,
transition_effect: str = "cut",
config: dict[str, Any] | None = None,
) -> FakeClip:
return FakeClip(
id=clip_id,
clip_type=clip_type,
order=order,
asset_id=asset_id or f"asset_{clip_id}.mp4",
duration=duration,
transition_effect=transition_effect,
config=config or {},
)
def _make_service(
clips: list[FakeClip] | None = None,
asset_paths: dict[str, Path] | None = None,
work_dir: Path | None = None,
) -> UnifiedRenderService:
"""创建测试用的 UnifiedRenderService 实例。
如果未提供 asset_paths,自动从 clips 生成默认映射
asset_id → /tmp/asset_{clip_id}.mp4)。
"""
plan = FakePlan()
clips = clips or []
work_dir = work_dir or Path("/tmp/test_render")
if asset_paths is None:
asset_paths = {}
for c in clips:
if c.asset_id:
asset_paths[c.asset_id] = Path(f"/tmp/{c.asset_id}")
return UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_paths,
work_dir=work_dir,
)
def _patch_path_exists():
"""Patch Path.exists() 让测试路径返回 True。"""
return patch("pathlib.Path.exists", return_value=True)
def _make_ctx() -> RenderContext:
"""创建测试用 RenderContext。"""
return RenderContext(work_dir=Path("/tmp/test_render"), plan_id="plan_001")
# ── 测试 _resolve_layer_role ─────────────────────────────────────────────────
class TestResolveLayerRole:
"""测试 clip_type → layer role 映射。"""
def test_main_default(self):
assert _resolve_layer_role("main", {}) == "main"
def test_main_with_b_roll_role(self):
assert _resolve_layer_role("main", {"role": "b_roll"}) == "broll"
def test_overlay(self):
assert _resolve_layer_role("overlay", {}) == "overlay"
def test_background(self):
assert _resolve_layer_role("background", {}) == "background"
def test_corner_voice(self):
assert _resolve_layer_role("corner_voice", {}) == "corner_voice"
def test_b_roll(self):
assert _resolve_layer_role("b_roll", {}) == "broll"
def test_intro(self):
assert _resolve_layer_role("intro", {}) == "main"
def test_outro(self):
assert _resolve_layer_role("outro", {}) == "main"
# ── 测试图层分组 ──────────────────────────────────────────────────────────────
class TestGroupClipsIntoLayers:
"""测试 _group_clips_into_layers 方法。"""
def test_group_clips_one_take(self):
"""4 个 main clips → 1 个 main_layer。"""
clips = [
_make_clip("c1", "main", order=0),
_make_clip("c2", "main", order=1),
_make_clip("c3", "main", order=2),
_make_clip("c4", "main", order=3),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert len(layers) == 1
assert layers[0].role == "main"
assert len(layers[0].clips) == 4
assert layers[0].z_index == 0
def test_group_clips_pip(self):
"""1 main + 2 overlay → main_layer + overlay_layer。"""
clips = [
_make_clip("c1", "main", order=0),
_make_clip("c2", "overlay", order=1),
_make_clip("c3", "overlay", order=2),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
roles = {lyr.role for lyr in layers}
assert "main" in roles
assert "overlay" in roles
main_layer = next(lyr for lyr in layers if lyr.role == "main")
overlay_layer = next(lyr for lyr in layers if lyr.role == "overlay")
assert len(main_layer.clips) == 1
assert len(overlay_layer.clips) == 2
assert overlay_layer.z_index > main_layer.z_index
def test_group_clips_voice_over(self):
"""3 个 main(b_roll) clips → 1 个 broll_layer。"""
clips = [
_make_clip("c1", "main", order=0, config={"role": "b_roll"}),
_make_clip("c2", "main", order=1, config={"role": "b_roll"}),
_make_clip("c3", "main", order=2, config={"role": "b_roll"}),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert len(layers) == 1
assert layers[0].role == "broll"
assert len(layers[0].clips) == 3
def test_group_clips_voice_pip(self):
"""1 background + 1 corner_voice + 2 b_roll → 3 layers。"""
clips = [
_make_clip("c1", "background", order=0),
_make_clip("c2", "corner_voice", order=1),
_make_clip("c3", "b_roll", order=2),
_make_clip("c4", "b_roll", order=3),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
roles = {lyr.role for lyr in layers}
assert roles == {"background", "corner_voice", "broll"}
assert len(layers) == 3
# z_index 排序
assert layers[0].z_index <= layers[1].z_index <= layers[2].z_index
def test_group_clips_intro_outro(self):
"""intro + 2 main + outro → 1 main_layer4 clips,按 order 排序)。"""
clips = [
_make_clip("intro", "intro", order=0),
_make_clip("c1", "main", order=1),
_make_clip("c2", "main", order=2),
_make_clip("outro", "outro", order=3),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert len(layers) == 1
assert layers[0].role == "main"
assert len(layers[0].clips) == 4
# 按 order 排序
orders = [c.order for c in layers[0].clips]
assert orders == [0, 1, 2, 3]
# ── 测试 _resolve_clips ──────────────────────────────────────────────────────
class TestResolveClips:
"""测试 _resolve_clips 方法。"""
def test_skip_missing_asset(self):
"""跳过 asset_id 在 asset_path_map 中找不到的 clip。"""
clips = [
_make_clip("c1", "main", order=0, asset_id="asset_1.mp4"),
_make_clip("c2", "main", order=1, asset_id="missing.mp4"),
]
# 只有 asset_1.mp4 存在
asset_paths = {"asset_1.mp4": Path("/tmp/asset_1.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()
assert len(resolved) == 1
assert resolved[0].clip_id == "c1"
def test_skip_empty_asset_id(self):
"""跳过 asset_id 为空的 clip。"""
clips = [
_make_clip("c1", "main", order=0, asset_id=""),
_make_clip("c2", "main", order=1, asset_id="asset_2.mp4"),
]
asset_paths = {"asset_2.mp4": Path("/tmp/asset_2.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()
assert len(resolved) == 1
assert resolved[0].clip_id == "c2"
def test_sort_by_order(self):
"""解析后的 clips 按 order 排序。"""
clips = [
_make_clip("c3", "main", order=3, asset_id="a3.mp4"),
_make_clip("c1", "main", order=1, asset_id="a1.mp4"),
_make_clip("c2", "main", order=2, asset_id="a2.mp4"),
]
asset_paths = {
"a1.mp4": Path("/tmp/a1.mp4"),
"a2.mp4": Path("/tmp/a2.mp4"),
"a3.mp4": Path("/tmp/a3.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()
orders = [c.order for c in resolved]
assert orders == [1, 2, 3]
# ── 测试 _build_filter_complex ───────────────────────────────────────────────
class TestBuildFilterComplex:
"""测试 _build_filter_complex 方法。"""
def test_single_layer_single_clip(self):
"""只有 1 个 main clip → 简单 scale + setpts。"""
clips = [_make_clip("c1", "main", order=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()
layers = svc._group_clips_into_layers(resolved)
fc, input_args = svc._build_filter_complex(layers)
assert "-i" in input_args
assert "/tmp/asset_c1.mp4" in input_args
assert "scale=" in fc
assert "[final_video]" in fc
def test_single_layer_multi_clips(self):
"""多个 main clips → xfade 串联。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.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):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
fc, input_args = svc._build_filter_complex(layers)
assert input_args.count("-i") == 2
assert "xfade=" in fc
assert "[final_video]" in fc
def test_with_overlay(self):
"""main + overlay → overlay 滤镜。"""
clips = [
_make_clip("c1", "main", order=0),
_make_clip("c2", "overlay", order=1),
]
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):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
fc, input_args = svc._build_filter_complex(layers)
assert "overlay=" in fc
assert "[final_video]" in fc
def test_setpts_before_fps_in_xfade_inputs(self):
"""多视频 xfade 模式:setpts=PTS-STARTPTS 必须在 fps 之前,确保 xfade 时各片段 PTS 一致。
构造两个不同时长的视频片段,验证生成的 filter_complex 中每个片段的
预处理滤镜链里 setpts 都在 fps 前面。
"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=5.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):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
fc, _ = svc._build_filter_complex(layers)
# 确保 xfade 存在
assert "xfade=" in fc
# 提取每个 clip 的预处理滤镜链([i:v]...[vi] 部分)
# 验证:每个 clip 滤镜链中,setpts=PTS-STARTPTS 的最后一次出现
# 必须在 fps= 的前面(PTS 归一化后再统一帧率)
import re
clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]")
matches = clip_pattern.findall(fc)
assert len(matches) == 2, f"Expected 2 clip preprocessing chains, got {len(matches)}"
for idx, chain_str in matches:
# 找到所有 setpts 和 fps 的位置
setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)]
fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)]
assert setpts_positions, f"clip {idx}: 未找到 setpts=PTS-STARTPTS"
assert fps_positions, f"clip {idx}: 未找到 fps="
# 最后一个 setpts 必须在第一个 fps 之前
last_setpts = max(setpts_positions)
first_fps = min(fps_positions)
assert last_setpts < first_fps, (
f"clip {idx}: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。"
f"滤镜链: {chain_str}"
)
def test_setpts_before_fps_single_clip(self):
"""单视频模式(一镜到底):setpts 也必须在 fps 之前。
单视频虽然没有 xfade,但滤镜链顺序应保持一致,确保 PTS 处理逻辑统一。
"""
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()
layers = svc._group_clips_into_layers(resolved)
fc, _ = svc._build_filter_complex(layers)
import re
clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]")
matches = clip_pattern.findall(fc)
assert len(matches) == 1
chain_str = matches[0][1]
setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)]
fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)]
assert setpts_positions, "单视频: 未找到 setpts=PTS-STARTPTS"
assert fps_positions, "单视频: 未找到 fps="
last_setpts = max(setpts_positions)
first_fps = min(fps_positions)
assert last_setpts < first_fps, (
f"单视频: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" f"滤镜链: {chain_str}"
)
def test_main_clip_uses_fill_crop_strategy(self):
"""main/broll clip 使用铺满裁剪策略(scale increase + crop),不是等比+黑边。
对齐链路A编辑器合成行为,与主流短视频平台一致。
"""
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()
layers = svc._group_clips_into_layers(resolved)
fc, _ = svc._build_filter_complex(layers)
# 验证:scale 使用 force_original_aspect_ratio=increase(铺满)
assert "force_original_aspect_ratio=increase" in fc
# 验证:有 crop(居中裁剪)
assert "crop=1280:720" in fc
# 验证:没有 pad(不是黑边模式)
assert "pad=" not in fc
def test_broll_clip_uses_fill_crop_strategy(self):
"""broll clip 同样使用铺满裁剪策略。"""
clips = [_make_clip("c1", "b_roll", 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()
layers = svc._group_clips_into_layers(resolved)
fc, _ = svc._build_filter_complex(layers)
assert "force_original_aspect_ratio=increase" in fc
assert "crop=1280:720" in fc
assert "pad=" not in fc
def test_background_uses_fill_crop_strategy(self):
"""background 层也使用铺满裁剪(已有的行为,保持一致)。"""
clips = [_make_clip("c1", "background", 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()
layers = svc._group_clips_into_layers(resolved)
fc, _ = svc._build_filter_complex(layers)
assert "force_original_aspect_ratio=increase" in fc
assert "crop=1280:720" in fc
def test_empty_layers_raises(self):
"""空图层列表抛出 ValueError。"""
svc = _make_service()
with pytest.raises(ValueError, match="没有可渲染的图层"):
svc._build_filter_complex([])
class TestPassThrough:
"""测试单图层单 clip 直通优化路径。"""
def test_can_use_pass_through_single_main_clip(self):
"""1个main图层 + 1个clip → 可以直通。"""
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert svc._can_use_pass_through(layers) is True
def test_can_use_pass_through_single_broll_clip(self):
"""1个broll图层 + 1个clip → 可以直通。"""
clips = [_make_clip("c1", "b_roll", order=0, duration=5.0)]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert svc._can_use_pass_through(layers) is True
def test_can_use_pass_through_single_background_clip(self):
"""1个background图层 + 1个clip → 可以直通。"""
clips = [_make_clip("c1", "background", order=0, duration=5.0)]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert svc._can_use_pass_through(layers) is True
def test_cannot_pass_through_multi_clips(self):
"""1个图层 + 多个clips → 不能直通(需要xfade)。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.0),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert svc._can_use_pass_through(layers) is False
def test_cannot_pass_through_multi_layers(self):
"""多个图层 → 不能直通。"""
clips = [
_make_clip("c1", "main", order=0, duration=5.0),
_make_clip("c2", "overlay", order=1, duration=5.0),
]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert svc._can_use_pass_through(layers) is False
def test_cannot_pass_through_overlay_only(self):
"""只有overlay图层 → 不能直通(需要叠加到主层)。"""
clips = [_make_clip("c1", "overlay", order=0, duration=5.0)]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
assert svc._can_use_pass_through(layers) is False
def test_render_uses_pass_through_for_single_clip(self):
"""单clip渲染时走直通路径(调用_render_pass_through而非_execute_ffmpeg)。"""
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") as mock_pass,
patch.object(svc, "_execute_ffmpeg") as mock_exec,
patch("video_processing.unified_render_service.mix_audio", return_value=None),
patch("shutil.copy2"),
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
):
result = svc.render()
mock_pass.assert_called_once()
mock_exec.assert_not_called()
assert result.duration == 5.0
def test_render_uses_filter_complex_for_multi_clips(self):
"""多clip渲染时走完整filter_complex路径。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.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, "_render_pass_through") as mock_pass,
patch.object(svc, "_execute_ffmpeg") as mock_exec,
patch("video_processing.unified_render_service.mix_audio", return_value=None),
patch("shutil.copy2"),
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
):
result = svc.render()
mock_pass.assert_not_called()
mock_exec.assert_called_once()
assert result.duration == 5.5
# ── 测试 ASS 字幕生成 ────────────────────────────────────────────────────────
class TestAssSubtitles:
"""测试 ASS 字幕生成功能。"""
def test_hex_to_ass_color_white(self):
"""#ffffff → &HFFFFFFASS 是 BGR 顺序)。"""
assert _hex_to_ass_color("#ffffff") == "&HFFFFFF"
def test_hex_to_ass_color_black(self):
"""#000000 → &H000000。"""
assert _hex_to_ass_color("#000000") == "&H000000"
def test_hex_to_ass_color_red(self):
"""#ff0000 红 → &H0000FFB=00, G=00, R=FF)。"""
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
def test_hex_to_ass_color_blue(self):
"""#0000ff 蓝 → &HFF0000B=FF, G=00, R=00)。"""
assert _hex_to_ass_color("#0000ff") == "&HFF0000"
def test_hex_to_ass_color_no_hash(self):
"""不带 # 的颜色值也能解析。"""
assert _hex_to_ass_color("ff0000") == "&H0000FF"
def test_position_to_ass_alignment_top(self):
"""top → 8(顶部居中)。"""
assert _position_to_ass_alignment("top") == 8
def test_position_to_ass_alignment_center(self):
"""center → 5(居中)。"""
assert _position_to_ass_alignment("center") == 5
def test_position_to_ass_alignment_bottom(self):
"""bottom → 2(底部居中)。"""
assert _position_to_ass_alignment("bottom") == 2
def test_generate_ass_with_title_only(self, tmp_path):
"""只有标题时生成 ASS 文件。"""
ass_path = tmp_path / "test.ass"
result = generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=10.0,
title_text="测试标题",
title_config={
"enabled": True,
"font": "思源黑体",
"size": 48,
"color": "#ffffff",
"bold": True,
"position": "top",
"stroke": {"enabled": True, "color": "#000000", "width": 2},
"shadow": {"enabled": True, "blur": 4, "offset_x": 2, "offset_y": 2},
},
)
assert result == ass_path
content = ass_path.read_text(encoding="utf-8")
assert "[Script Info]" in content
assert "PlayResX: 1280" in content
assert "PlayResY: 720" in content
assert "[V4+ Styles]" in content
assert "TitleStyle" in content
assert "测试标题" in content
assert "Dialogue:" in content
def test_generate_ass_with_subtitle_only(self, tmp_path):
"""只有字幕时生成 ASS 文件。"""
ass_path = tmp_path / "test.ass"
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_text="测试字幕内容",
subtitle_config={
"enabled": True,
"font": "思源黑体",
"size": 24,
"color": "#ffffff",
"position": "bottom",
},
)
content = ass_path.read_text(encoding="utf-8")
assert "SubtitleStyle" in content
assert "测试字幕内容" in content
assert "Dialogue:" in content
def test_generate_ass_with_both_title_and_subtitle(self, tmp_path):
"""同时有标题和字幕。"""
ass_path = tmp_path / "test.ass"
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=10.0,
title_text="大标题",
title_config={"enabled": True, "position": "top"},
subtitle_text="底部字幕",
subtitle_config={"enabled": True, "position": "bottom"},
)
content = ass_path.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "SubtitleStyle" in content
assert "大标题" in content
assert "底部字幕" in content
# 两条 Dialogue 行
assert content.count("Dialogue:") == 2
def test_generate_ass_disabled_returns_empty(self, tmp_path):
"""标题和字幕都禁用时返回空文件。"""
ass_path = tmp_path / "test.ass"
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=10.0,
title_text="不显示",
title_config={"enabled": False},
subtitle_text="也不显示",
subtitle_config={"enabled": False},
)
content = ass_path.read_text(encoding="utf-8")
assert content == ""
def test_generate_ass_empty_text_returns_empty(self, tmp_path):
"""文本为空时不生成字幕。"""
ass_path = tmp_path / "test.ass"
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=10.0,
title_text="",
title_config={"enabled": True},
subtitle_text=" ",
subtitle_config={"enabled": True},
)
content = ass_path.read_text(encoding="utf-8")
assert content == ""
def test_generate_ass_time_format(self, tmp_path):
"""验证 ASS 时间格式正确(H:MM:SS.cc)。"""
ass_path = tmp_path / "test.ass"
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=125.5, # 2分5.5秒
title_text="测试",
title_config={"enabled": True},
)
content = ass_path.read_text(encoding="utf-8")
# 结束时间应该是 0:02:05.50
assert "0:02:05.50" in content
def test_ass_text_escape_newlines(self, tmp_path):
"""换行符转义为 ASS 的 \\N。"""
ass_path = tmp_path / "test.ass"
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
video_duration=10.0,
title_text="第一行\n第二行",
title_config={"enabled": True},
)
content = ass_path.read_text(encoding="utf-8")
assert "第一行\\N第二行" in content
# ── 测试 render 方法 ─────────────────────────────────────────────────────────
class TestRender:
"""测试 render 方法。"""
def test_render_empty_clips_raises(self):
"""没有 clips 时抛出 ValueError。"""
svc = _make_service(clips=[], asset_paths={})
with pytest.raises(ValueError, match="没有可渲染的片段"):
svc.render()
def test_render_with_missing_assets_raises(self):
"""所有 clips 素材缺失时抛出 ValueError。"""
clips = [_make_clip("c1", "main", order=0, asset_id="missing.mp4")]
svc = _make_service(clips, asset_paths={})
with pytest.raises(ValueError, match="没有可渲染的片段"):
svc.render()
def test_render_success_single_clip(self):
"""单clip正常渲染(走直通路径)。"""
clips = [_make_clip("c1", "main", order=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") as mock_pass,
patch("video_processing.unified_render_service.mix_audio", return_value=None),
patch("shutil.copy2"),
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
):
result = svc.render()
assert isinstance(result, RenderResult)
assert result.duration == 5.0
assert result.file_size == 1024
assert result.width == 1280
assert result.height == 720
mock_pass.assert_called_once()
def test_render_success_multi_clips(self):
"""多clip正常渲染(走完整filter_complex路径)。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.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") as mock_exec,
patch("video_processing.unified_render_service.mix_audio", return_value=None),
patch("shutil.copy2"),
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
):
result = svc.render()
assert isinstance(result, RenderResult)
assert result.duration == 5.5
assert result.file_size == 2048
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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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)
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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_durationvideo_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.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
# video_duration 只有 3.0,小于 clip 的 10.0
result = mix_audio(ctx, 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):
"""合并音视频命令正确。"""
video_path = Path("/tmp/video.mp4")
audio_path = Path("/tmp/audio.aac")
output_path = Path("/tmp/output.mp4")
ctx = _make_ctx()
with patch("video_processing.render_audio.run_ffmpeg") as mock_run:
merge_audio_video(ctx, 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("video_processing.unified_render_service.mix_audio", return_value=Path("/tmp/audio.aac")) as mock_mix,
patch("video_processing.unified_render_service.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("video_processing.unified_render_service.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("video_processing.unified_render_service.mix_audio") as mock_mix,
patch("video_processing.unified_render_service.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.render_audio.probe_has_audio", return_value=False),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.probe_has_audio", side_effect=fake_has_audio),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.probe_has_audio", side_effect=fake_has_audio),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.probe_has_audio", return_value=False),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(svc._resolve_clips())
ctx = _make_ctx()
result = mix_audio(ctx, 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.render_audio.probe_has_audio", return_value=True) as mock_probe:
# 调用 3 次
ctx = _make_ctx()
r1 = clip_has_audio(ctx, clip)
r2 = clip_has_audio(ctx, clip)
r3 = clip_has_audio(ctx, clip)
assert r1 is True and r2 is True and r3 is True
# 实际只探测了 1 次
assert mock_probe.call_count == 1
# ── 测试 stream copy 流拷贝优化 ───────────────────────────────────────────────
class TestStreamCopy:
"""stream copy 流拷贝优化测试。"""
def _make_single_clip_service(self):
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
svc = _make_service(clips)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
return svc, resolved[0], layers
def test_can_use_stream_copy_all_conditions_met(self):
"""所有条件满足 → 可以 stream copy。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1280,
"height": 720,
"fps": 25.0,
"video_codec": "h264",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
with patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
):
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
assert can_copy is True
assert "所有条件满足" in reason
def test_cannot_copy_with_subtitles(self):
"""有字幕 → 不能 stream copy。"""
svc, clip, layers = self._make_single_clip_service()
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=Path("/tmp/sub.ass"), video_duration=0)
assert can_copy is False
assert "字幕" in reason
def test_cannot_copy_wrong_codec(self):
"""编码不是 h264 → 不能 stream copy。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1280,
"height": 720,
"fps": 25.0,
"video_codec": "hevc",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
with patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
):
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
assert can_copy is False
assert "编码" in reason
def test_cannot_copy_wrong_resolution(self):
"""分辨率不匹配 → 不能 stream copy。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1920,
"height": 1080,
"fps": 25.0,
"video_codec": "h264",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
with patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
):
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
assert can_copy is False
assert "分辨率" in reason
def test_cannot_copy_wrong_fps(self):
"""帧率不匹配 → 不能 stream copy。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1280,
"height": 720,
"fps": 30.0,
"video_codec": "h264",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
with patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
):
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
assert can_copy is False
assert "帧率" in reason
def test_cannot_copy_wrong_pix_fmt(self):
"""像素格式不匹配 → 不能 stream copy。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1280,
"height": 720,
"fps": 25.0,
"video_codec": "h264",
"pix_fmt": "yuv422p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
with patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
):
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
assert can_copy is False
assert "像素格式" in reason
def test_try_render_stream_copy_success(self):
"""stream copy 渲染成功 → 返回 True。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1280,
"height": 720,
"fps": 25.0,
"video_codec": "h264",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
output_path = Path("/tmp/test_output.mp4")
def fake_stat():
m = MagicMock()
m.st_size = 1024000
return m
with (
patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
),
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.stat", side_effect=fake_stat),
):
result = svc._try_render_stream_copy(layers, output_path, ass_path=None, video_duration=0)
assert result is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "-c:v" in cmd
assert "copy" in cmd
assert "-c:a" in cmd
def test_try_render_stream_copy_fallback_on_ffmpeg_error(self):
"""stream copy FFmpeg 失败 → 返回 False(调用方回退到重编码)。"""
svc, clip, layers = self._make_single_clip_service()
probe_result = {
"width": 1280,
"height": 720,
"fps": 25.0,
"video_codec": "h264",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
output_path = Path("/tmp/test_output.mp4")
import subprocess as sp
with (
patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
),
patch(
"video_processing.unified_render_service.run_ffmpeg",
side_effect=sp.CalledProcessError(1, ["ffmpeg"], stderr="copy failed"),
),
patch("pathlib.Path.exists", return_value=False),
):
result = svc._try_render_stream_copy(layers, output_path, ass_path=None, video_duration=0)
assert result is False
def test_render_uses_stream_copy_when_eligible(self):
"""完整渲染流程:满足条件时走 stream copy。"""
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
svc = _make_service(clips)
probe_result = {
"width": 1280,
"height": 720,
"fps": 25.0,
"video_codec": "h264",
"pix_fmt": "yuv420p",
"duration": 5.0,
"has_audio": True,
"audio_codec": "aac",
}
def fake_stat():
m = MagicMock()
m.st_size = 1024000
return m
with (
_patch_path_exists(),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch(
"video_processing.unified_render_service.probe_video_info",
return_value=probe_result,
),
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
patch("pathlib.Path.stat", side_effect=fake_stat),
patch("shutil.copy2"),
):
result = svc.render()
assert mock_run.call_count == 1
cmd = mock_run.call_args[0][0]
assert "copy" in cmd
assert isinstance(result.output_path, Path)