61770cd4e4
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m9s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m10s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m28s
AI Code Review / AI Code Review (pull_request) Failing after 2m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m15s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m12s
CI/CD Pipeline / Unit Tests (push) Failing after 13m33s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 11m30s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 10s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2852 lines
114 KiB
Python
Executable File
2852 lines
114 KiB
Python
Executable File
"""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"
|
||
transition_duration: float = 0.0
|
||
status: str = "ready"
|
||
playback_speed: float = 1.0
|
||
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)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _assume_source_clips_have_audio():
|
||
"""默认假设测试中的视频素材都带音频流。
|
||
|
||
新行为:保留视频素材原声(不再无条件丢弃)。需要模拟无音频流的用例
|
||
自行 patch probe_has_audio=False(如 test_mix_audio_main_no_audio_stream_returns_none)。
|
||
"""
|
||
with patch("video_processing.render_audio.probe_has_audio", return_value=True):
|
||
yield
|
||
|
||
|
||
def _make_clip(
|
||
clip_id: str,
|
||
clip_type: str = "main",
|
||
order: int = 0,
|
||
asset_id: str = "",
|
||
duration: float = 0.0,
|
||
transition_effect: str = "cut",
|
||
transition_duration: float = 0.0,
|
||
config: dict[str, Any] | None = None,
|
||
playback_speed: float = 1.0,
|
||
) -> 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,
|
||
transition_duration=transition_duration,
|
||
config=config or {},
|
||
playback_speed=playback_speed,
|
||
)
|
||
|
||
|
||
def _make_service(
|
||
clips: list[FakeClip] | None = None,
|
||
asset_paths: dict[str, Path] | None = None,
|
||
work_dir: Path | None = None,
|
||
output_fps: int = 25,
|
||
output_width: int = 1280,
|
||
output_height: int = 720,
|
||
) -> 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,
|
||
output_width=output_width,
|
||
output_height=output_height,
|
||
output_fps=output_fps,
|
||
)
|
||
|
||
|
||
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_layer(4 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(默认硬切)→ concat 串联。"""
|
||
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
|
||
# 全硬切场景走 concat filter(性能远优于 xfade)
|
||
assert "concat=n=2:v=1:a=0" in fc
|
||
assert "[final_video]" in fc
|
||
|
||
def test_single_layer_multi_clips_with_transition(self):
|
||
"""多个 main clips 带转场效果 → xfade 串联。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip(
|
||
"c2",
|
||
"main",
|
||
order=1,
|
||
duration=3.0,
|
||
transition_effect="fade",
|
||
transition_duration=0.5,
|
||
),
|
||
]
|
||
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, transition_effect="fade", transition_duration=0.5),
|
||
]
|
||
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_pad_strategy(self):
|
||
"""main clip 使用 scale+pad 保持比例留黑边(不裁剪内容)。
|
||
|
||
渲染引擎不能挑素材,任何素材进来都统一规格后出片;pad留黑边保证内容完整。
|
||
"""
|
||
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=decrease(等比缩小)
|
||
assert "force_original_aspect_ratio=decrease" in fc
|
||
# 验证:有 pad(留黑边)
|
||
assert "pad=" in fc
|
||
assert "black" in fc
|
||
# 验证:没有 crop(不是裁剪模式)
|
||
assert "crop=" not in fc
|
||
|
||
def test_broll_clip_uses_pad_strategy(self):
|
||
"""broll clip 同样使用 scale+pad 留黑边策略。"""
|
||
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=decrease" in fc
|
||
assert "pad=" in fc
|
||
assert "crop=" 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 → &HFFFFFF(ASS 是 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 红 → &H0000FF(B=00, G=00, R=FF)。"""
|
||
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
|
||
|
||
def test_hex_to_ass_color_blue(self):
|
||
"""#0000ff 蓝 → &HFF0000(B=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):
|
||
"""只有 main clip(无独立音频轨)→ 保留源视频原声,走单轨 concat。"""
|
||
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
|
||
mock_run.assert_called_once()
|
||
assert "asset_c1.mp4" in " ".join(mock_run.call_args[0][0])
|
||
|
||
def test_mix_audio_multi_main_clips(self):
|
||
"""main 原声与独立音频轨通过 amix 混音。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
|
||
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
|
||
"asset_tts2.mp4": Path("/tmp/asset_tts2.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]
|
||
cmd_str = " ".join(cmd)
|
||
# 原声 + 2 个独立音频轨 → amix 混音(3 路输入)
|
||
assert "amix=inputs=3" in cmd_str
|
||
# main 原声 c1 参与混音
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_tts1.mp4" in cmd_str
|
||
assert "asset_tts2.mp4" in cmd_str
|
||
|
||
def test_mix_audio_with_independent_audio_track(self):
|
||
"""main 原声与独立音频轨通过 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)
|
||
# main 原声 c1 与独立音频轨 bgm1 都参与 amix
|
||
assert "amix=inputs=2" in cmd_str
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_bgm1.mp4" in cmd_str
|
||
|
||
def test_mix_with_independent_audio_amix(self):
|
||
"""直接验证 mix_with_independent_audio:main 音频 + 独立音频轨走 amix 混音。"""
|
||
from video_processing.render_audio import mix_with_independent_audio
|
||
from video_processing.unified_render_service import ResolvedClip
|
||
|
||
main = ResolvedClip(
|
||
clip_id="c1",
|
||
asset_id="asset_c1",
|
||
clip_type="main",
|
||
order=0,
|
||
local_path=Path("/tmp/asset_c1.mp4"),
|
||
duration=5.0,
|
||
actual_duration=5.0,
|
||
config={},
|
||
)
|
||
aud = ResolvedClip(
|
||
clip_id="bgm1",
|
||
asset_id="asset_bgm1",
|
||
clip_type="main",
|
||
order=1,
|
||
local_path=Path("/tmp/asset_bgm1.mp4"),
|
||
duration=5.0,
|
||
actual_duration=5.0,
|
||
config={"volume": 0.5},
|
||
)
|
||
ctx = _make_ctx()
|
||
output_path = ctx.work_dir / "audio_plan_001.aac"
|
||
|
||
with patch("video_processing.render_audio.run_ffmpeg") as mock_run:
|
||
mix_with_independent_audio(ctx, [main], [aud], output_path, 5.0)
|
||
|
||
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
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_bgm1.mp4" 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),
|
||
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_bg1.mp4": Path("/tmp/asset_bg1.mp4"),
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.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)
|
||
# background 图层不参与主音频
|
||
assert "asset_bg1.mp4" not in cmd_str
|
||
# main 原声 + 独立音频轨都参与
|
||
assert "amix=inputs=2" in cmd_str
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_tts1.mp4" in cmd_str
|
||
|
||
def test_mix_audio_main_priority_over_broll(self):
|
||
"""main 图层优先作为主音频,broll 不参与;与独立音频轨 amix。"""
|
||
clips = [
|
||
_make_clip("b1", "b_roll", order=0, duration=5.0),
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_b1.mp4": Path("/tmp/asset_b1.mp4"),
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.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 "amix=inputs=2" in cmd_str
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_b1.mp4" not in cmd_str
|
||
assert "asset_tts1.mp4" 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)
|
||
|
||
# 保留 broll 原声
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
assert "asset_b1.mp4" in " ".join(mock_run.call_args[0][0])
|
||
|
||
def test_mix_audio_single_clip_truncated_to_video_duration(self):
|
||
"""主音频截断到 video_duration(video_duration < clip有效时长)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=10.0),
|
||
_make_clip("tts1", "main", order=0, duration=10.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.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,小于独立音频轨的 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]
|
||
# 验证截断到 3.0(-t 3.0 或 atrim=0:3.000)
|
||
cmd_str = " ".join(cmd)
|
||
assert "3.000" in cmd_str or "3.0" in cmd_str
|
||
# main 原声参与
|
||
assert "asset_c1.mp4" in cmd_str
|
||
|
||
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):
|
||
"""main 原声正常保留;无音频流的 clip 被过滤。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=2.0),
|
||
_make_clip("tts1", "main", order=0, duration=2.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.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/c2 + 独立音频轨 tts1 全部参与
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_c2.mp4" in cmd_str
|
||
assert "asset_tts1.mp4" in cmd_str
|
||
|
||
def test_mix_audio_main_plus_independent_amix(self):
|
||
"""main 原声与独立音频轨 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)
|
||
# main 原声 c1 与独立音频轨 bgm1 都参与 amix
|
||
assert "amix=inputs=2" in cmd_str
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_bgm1.mp4" 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)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# _extract_audio 安全下沉测试(P1 技术债务)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestExtractAudioUsesRunFfmpeg:
|
||
"""_extract_audio 必须使用 run_ffmpeg 统一管理,不能用裸 subprocess."""
|
||
|
||
def test_extract_audio_calls_run_ffmpeg(self, tmp_path):
|
||
"""_extract_audio 内部应调用 ffmpeg_utils.run_ffmpeg 而非裸 subprocess."""
|
||
from video_processing.unified_render_service import UnifiedRenderService
|
||
|
||
plan = MagicMock()
|
||
plan.config = {}
|
||
plan.id = "test-plan"
|
||
plan.canvas_config = MagicMock()
|
||
plan.canvas_config.width = 1080
|
||
plan.canvas_config.height = 1920
|
||
plan.canvas_config.fps = 30
|
||
plan.canvas_config.output_width = 1080
|
||
plan.canvas_config.output_height = 1920
|
||
|
||
svc = UnifiedRenderService(plan, [], {}, tmp_path)
|
||
|
||
video_path = tmp_path / "input.mp4"
|
||
output_path = tmp_path / "output.wav"
|
||
video_path.write_bytes(b"fake")
|
||
|
||
with patch("video_processing.unified_render_service.run_ffmpeg") as mock_run:
|
||
svc._extract_audio(video_path, output_path)
|
||
|
||
# 验证调用了 run_ffmpeg
|
||
assert mock_run.called, "_extract_audio 必须通过 run_ffmpeg 执行 FFmpeg"
|
||
cmd = mock_run.call_args[0][0]
|
||
|
||
# 验证命令参数正确
|
||
assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0]
|
||
assert "-i" in cmd
|
||
assert str(video_path) in cmd
|
||
assert "-vn" in cmd # 无视频流
|
||
assert "pcm_s16le" in cmd # 16bit PCM
|
||
assert "16000" in cmd # 16kHz
|
||
assert str(output_path) in cmd
|
||
assert mock_run.call_args[1].get("timeout") == 120
|
||
|
||
def test_extract_audio_failure_raises_runtime_error(self, tmp_path):
|
||
"""_extract_audio 失败时应抛出 RuntimeError."""
|
||
from video_processing.unified_render_service import UnifiedRenderService
|
||
|
||
plan = MagicMock()
|
||
plan.config = {}
|
||
plan.id = "test-plan"
|
||
plan.canvas_config = MagicMock()
|
||
plan.canvas_config.width = 1080
|
||
plan.canvas_config.height = 1920
|
||
plan.canvas_config.fps = 30
|
||
plan.canvas_config.output_width = 1080
|
||
plan.canvas_config.output_height = 1920
|
||
|
||
svc = UnifiedRenderService(plan, [], {}, tmp_path)
|
||
|
||
video_path = tmp_path / "input.mp4"
|
||
output_path = tmp_path / "output.wav"
|
||
video_path.write_bytes(b"fake")
|
||
|
||
import subprocess
|
||
|
||
with patch("video_processing.unified_render_service.run_ffmpeg") as mock_run:
|
||
mock_run.side_effect = subprocess.CalledProcessError(1, "ffmpeg", stderr="error")
|
||
with pytest.raises(RuntimeError, match="音频提取失败"):
|
||
svc._extract_audio(video_path, output_path)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# concat 前 4 项归一化测试(修复 exit=234)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestConcatNormalizeVideoResolution:
|
||
"""视频分辨率归一化:scale + pad 保持比例留黑边,确保 concat 前分辨率一致。
|
||
|
||
对应 4 项 normalize 之:视频分辨率(scale + pad 到 canvas_width × canvas_height)
|
||
修复前:main/broll 用 scale+crop(铺满裁剪),横屏素材内容被裁掉
|
||
修复后:main/broll 用 scale+pad(留黑边),保持原始比例不裁剪
|
||
"""
|
||
|
||
def test_main_layer_uses_pad_not_crop_in_filter_complex(self):
|
||
"""_build_filter_complex 中 main 层用 scale+pad(letterbox),不用 crop。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=3.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)
|
||
|
||
# 必须用 pad(留黑边),不能用 crop(裁剪)
|
||
assert "force_original_aspect_ratio=decrease" in fc, "main 层应使用 decrease 模式(等比缩放到画布内)"
|
||
assert "pad=" in fc, "main 层应有 pad 滤镜(留黑边到目标分辨率)"
|
||
assert "trunc((ow-iw)/2):trunc((oh-ih)/2):black" in fc, "pad 应该居中取整+黑边"
|
||
# 注意:background 层也用 crop,但 main/broll 应该用 pad
|
||
# 检查第一个 [0:v] 处理链(第一个 clip 是 main)
|
||
first_v_chain = fc.split("[v0]")[0]
|
||
assert "crop=" not in first_v_chain, "main 层不应该有 crop 滤镜"
|
||
|
||
def test_broll_layer_uses_pad_not_crop_in_filter_complex(self):
|
||
"""_build_filter_complex 中 b_roll 层用 scale+pad,不用 crop。"""
|
||
clips = [
|
||
_make_clip("c1", "b_roll", order=0, duration=3.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=decrease" in fc
|
||
assert "pad=" in fc
|
||
first_v_chain = fc.split("[v0]")[0]
|
||
assert "crop=" not in first_v_chain, "broll 层不应该有 crop 滤镜"
|
||
|
||
def test_background_layer_still_uses_crop_in_filter_complex(self):
|
||
"""_build_filter_complex 中 background 层保持 scale+crop(作为底图铺满)。"""
|
||
clips = [
|
||
_make_clip("bg1", "background", order=0, duration=3.0),
|
||
_make_clip("c1", "main", order=0, duration=3.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):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
# background 应该用 increase + crop(cover 模式)
|
||
# 统计 crop 出现次数:background 1次 + 其他位置(如果有)
|
||
# 关键是 background 走 increase 模式
|
||
assert "force_original_aspect_ratio=increase" in fc, "background 层应使用 increase 模式(铺满裁剪)"
|
||
|
||
def test_multi_clip_concat_all_same_resolution(self):
|
||
"""多 clip concat 时,所有 clip 预处理后分辨率一致(pad 到同一尺寸)。"""
|
||
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):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
# 两个 clip 都应该有 pad
|
||
pad_count = fc.count("pad=")
|
||
assert pad_count >= 2, f"至少应有 2 个 pad(每个 clip 一个),实际 {pad_count}"
|
||
# 都用 decrease 模式
|
||
decrease_count = fc.count("force_original_aspect_ratio=decrease")
|
||
assert decrease_count >= 2, f"至少应有 2 个 decrease,实际 {decrease_count}"
|
||
# 有 concat
|
||
assert "concat=n=2:v=1:a=0" in fc
|
||
|
||
def test_pass_through_main_uses_pad_not_crop(self):
|
||
"""_render_pass_through 中 main 层用 scale+pad,不用 crop。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=3.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,
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=3.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# 直通模式 main 层应该用 pad
|
||
assert "force_original_aspect_ratio=decrease" in cmd_str, "直通模式 main 层应使用 decrease 模式"
|
||
assert "pad=" in cmd_str, "直通模式应有 pad 滤镜"
|
||
# 检查 vf 中没有 crop
|
||
vf_idx = cmd.index("-vf")
|
||
vf_value = cmd[vf_idx + 1]
|
||
assert "crop=" not in vf_value, "直通模式 main 层 vf 中不应该有 crop"
|
||
|
||
def test_pass_through_background_uses_crop(self):
|
||
"""_render_pass_through 中 background 层保持 scale+crop。"""
|
||
clips = [_make_clip("bg1", "background", order=0, duration=3.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,
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=3.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# background 层应该用 increase + crop
|
||
assert "force_original_aspect_ratio=increase" in cmd_str
|
||
vf_idx = cmd.index("-vf")
|
||
vf_value = cmd[vf_idx + 1]
|
||
assert "crop=" in vf_value, "background 层应有 crop 滤镜"
|
||
|
||
def test_pass_through_speed_up_correct_duration(self):
|
||
"""直通模式加速(speed=2x):视频+音频均调速,-t 时长为原始的 1/2。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0, playback_speed=2.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,
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
svc._render_pass_through(layers, Path("/tmp/out.mp4"))
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# 视频调速:setpts=PTS/2.0
|
||
assert "setpts=PTS/2.0" in cmd_str, "加速场景应有 setpts=PTS/speed 滤镜"
|
||
|
||
# 音频调速:atempo
|
||
assert "atempo" in cmd_str, "加速场景应有 atempo 音频调速滤镜"
|
||
|
||
# 输出时长应为原始 / speed = 10 / 2 = 5 秒
|
||
t_idx = cmd.index("-t")
|
||
t_value = float(cmd[t_idx + 1])
|
||
assert abs(t_value - 5.0) < 0.01, f"加速后 -t 时长应为 5.0s,实际 {t_value}s"
|
||
|
||
def test_pass_through_slow_down_correct_duration(self):
|
||
"""直通模式减速(speed=0.5x):视频+音频均调速,-t 时长为原始的 2 倍(不被截断)。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0, playback_speed=0.5)]
|
||
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,
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
svc._render_pass_through(layers, Path("/tmp/out.mp4"))
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# 视频调速:setpts=PTS/0.5
|
||
assert "setpts=PTS/0.5" in cmd_str, "减速场景应有 setpts=PTS/speed 滤镜"
|
||
|
||
# 音频调速:atempo
|
||
assert "atempo" in cmd_str, "减速场景应有 atempo 音频调速滤镜"
|
||
|
||
# 输出时长应为原始 / speed = 10 / 0.5 = 20 秒(减速后视频变长,不应被截断)
|
||
t_idx = cmd.index("-t")
|
||
t_value = float(cmd[t_idx + 1])
|
||
assert abs(t_value - 20.0) < 0.01, f"减速后 -t 时长应为 20.0s,实际 {t_value}s"
|
||
|
||
def test_pass_through_speed_with_video_duration_cap(self):
|
||
"""直通模式调速 + video_duration 截断:取调速后时长与 video_duration 的较小值。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0, playback_speed=2.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,
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
# video_duration=3.0 < 调速后时长 5.0,应取 3.0
|
||
svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=3.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
t_idx = cmd.index("-t")
|
||
t_value = float(cmd[t_idx + 1])
|
||
assert abs(t_value - 3.0) < 0.01, f"video_duration 更小时应取 video_duration,实际 {t_value}s"
|
||
|
||
|
||
class TestConcatNormalizeVideoFps:
|
||
"""视频帧率归一化:fps 滤镜统一到目标 fps。
|
||
|
||
对应 4 项 normalize 之:视频帧率(fps 滤镜统一到目标 fps)
|
||
"""
|
||
|
||
def test_filter_complex_has_fps_filter(self):
|
||
"""_build_filter_complex 中每个 clip 都有 fps 滤镜。"""
|
||
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, output_fps=30)
|
||
|
||
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)
|
||
|
||
fps_count = fc.count("fps=30")
|
||
assert fps_count >= 2, f"至少应有 2 个 fps=30(每个 clip 一个),实际 {fps_count}"
|
||
|
||
def test_fps_after_pad_before_final_setpts(self):
|
||
"""fps 滤镜在 pad 之后、setpts 之前(确保分辨率和帧率都统一后再归一化 PTS)。"""
|
||
import re
|
||
|
||
clips = [_make_clip("c1", "main", order=0, duration=3.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths, output_fps=30)
|
||
|
||
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)
|
||
|
||
# 提取第一个 clip 的视频处理链
|
||
chain_str = fc.split("[v0]")[0]
|
||
|
||
pad_pos = chain_str.find("pad=")
|
||
fps_pos = chain_str.find("fps=")
|
||
assert pad_pos >= 0, "应找到 pad 滤镜"
|
||
assert fps_pos >= 0, "应找到 fps 滤镜"
|
||
assert fps_pos > pad_pos, "fps 应在 pad 之后"
|
||
|
||
|
||
class TestConcatNormalizeAudioFormat:
|
||
"""音频格式归一化:aformat 统一为 stereo + 48000Hz + fltp。
|
||
|
||
对应 4 项 normalize 之:音频声道(aformat 统一为 stereo + 48000Hz + fltp)
|
||
修复前:concat 前音频无归一化,不同采样率/声道导致 exit=234
|
||
修复后:concat/amix 前所有音频统一为 48000Hz + stereo + fltp
|
||
"""
|
||
|
||
def test_multi_clip_concat_has_aformat(self):
|
||
"""main 原声 + 独立音频轨在 concat/amix 前都有 aformat 归一化。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
|
||
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
|
||
"asset_tts2.mp4": Path("/tmp/asset_tts2.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()
|
||
mix_audio(ctx, layers, 4.5)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# 必须有 aformat 归一化
|
||
assert "aformat=" in cmd_str, "concat 前应有 aformat 滤镜"
|
||
assert "sample_rates=48000" in cmd_str, "采样率应统一为 48000Hz"
|
||
assert "channel_layouts=stereo" in cmd_str, "声道应统一为 stereo"
|
||
assert "sample_fmts=fltp" in cmd_str, "采样格式应统一为 fltp"
|
||
|
||
# main 原声 + 2 个独立音频轨都应有 aformat
|
||
aformat_count = cmd_str.count("aformat=")
|
||
assert aformat_count >= 3, f"3 路音频都应有 aformat,实际 {aformat_count} 个"
|
||
|
||
# amix 3 路输入
|
||
assert "amix=inputs=3" in cmd_str
|
||
# main 源视频原声参与
|
||
assert "asset_c1.mp4" in cmd_str
|
||
|
||
def test_aformat_before_concat(self):
|
||
"""aformat 应在 concat 之前(每个独立音频轨处理链中 aformat 在 concat 之前)。"""
|
||
clips = [
|
||
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
|
||
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
|
||
"asset_tts2.mp4": Path("/tmp/asset_tts2.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()
|
||
mix_audio(ctx, layers, 4.5)
|
||
|
||
cmd = mock_run.call_args[0][0]
|
||
fc_idx = cmd.index("-filter_complex")
|
||
fc_str = cmd[fc_idx + 1]
|
||
|
||
concat_pos = fc_str.find("concat=")
|
||
assert concat_pos > 0, "应找到 concat filter"
|
||
|
||
before_concat = fc_str[:concat_pos]
|
||
aformat_before_count = before_concat.count("aformat=")
|
||
assert aformat_before_count >= 2, f"concat 之前每个独立音频轨都应有 aformat,实际 {aformat_before_count} 个"
|
||
|
||
def test_single_clip_audio_has_normalized_output(self):
|
||
"""原声+独立音频轨输出统一格式(一致性保障)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.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()
|
||
mix_audio(ctx, layers, 5.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# 原声 + 独立音频轨走 amix:两路都有 aformat 归一化
|
||
assert "amix=inputs=2" in cmd_str
|
||
assert cmd_str.count("aformat=") >= 2
|
||
assert "sample_rates=48000" in cmd_str
|
||
assert "channel_layouts=stereo" in cmd_str
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_tts1.mp4" in cmd_str
|
||
|
||
def test_independent_audio_track_has_aformat(self):
|
||
"""原声+独立音频轨输出统一格式(48000Hz + stereo + aac)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip(
|
||
"audio1",
|
||
"main",
|
||
order=0,
|
||
duration=5.0,
|
||
config={"role": "audio", "volume": 0.5},
|
||
),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_audio1.mp4": Path("/tmp/asset_audio1.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()
|
||
mix_audio(ctx, layers, 5.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
# 原声 + 独立音频轨走 amix:两路都有 aformat 归一化
|
||
assert "amix=inputs=2" in cmd_str
|
||
assert cmd_str.count("aformat=") >= 2
|
||
assert "sample_rates=48000" in cmd_str
|
||
assert "channel_layouts=stereo" in cmd_str
|
||
# main 原声 c1 与独立音频轨 audio1 都参与
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_audio1.mp4" in cmd_str
|
||
|
||
|
||
class TestConcatNormalizeAudioCodec:
|
||
"""音频编码归一化:统一为 aac。
|
||
|
||
对应 4 项 normalize 之:音频编码(统一为 aac)
|
||
"""
|
||
|
||
def test_multi_clip_output_is_aac(self):
|
||
"""多独立音频轨 concat 后输出编码为 aac。"""
|
||
clips = [
|
||
_make_clip("tts1", "main", order=0, duration=3.0, config={"role": "audio"}),
|
||
_make_clip("tts2", "main", order=1, duration=2.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.mp4"),
|
||
"asset_tts2.mp4": Path("/tmp/asset_tts2.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()
|
||
mix_audio(ctx, layers, 4.5)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "aac" in cmd, "音频输出编码应为 aac"
|
||
assert "-b:a" in cmd, "应指定音频码率"
|
||
|
||
def test_single_clip_output_is_aac(self):
|
||
"""原声+独立音频轨输出编码为 aac。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip("tts1", "main", order=0, duration=5.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_tts1.mp4": Path("/tmp/asset_tts1.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()
|
||
mix_audio(ctx, layers, 5.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "aac" in cmd
|
||
# main 原声参与
|
||
assert "asset_c1.mp4" in " ".join(cmd)
|
||
|
||
|
||
class TestConcatNormalizeFourItemsComplete:
|
||
"""4 项 normalize 完整性验证:one_take 多素材 concat 场景全链路检查。
|
||
|
||
模拟 one_take 模式(多个 main clip)的完整渲染路径,
|
||
验证 concat 前所有 4 项 normalize 都已到位。
|
||
"""
|
||
|
||
def _make_one_take_clips(self):
|
||
"""构造 one_take 模式的典型素材:3 个 main clip(模拟横屏+竖屏混合)。"""
|
||
return [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip("c2", "main", order=1, duration=4.0),
|
||
_make_clip("c3", "main", order=2, duration=3.0),
|
||
]
|
||
|
||
def test_video_resolution_normalized(self):
|
||
"""[1/4] 视频分辨率:所有 clip 都有 pad 到统一分辨率。"""
|
||
clips = self._make_one_take_clips()
|
||
asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=10.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
# 3 个 clip 都应该有 pad(decrease + black padding)
|
||
pad_count = fc.count("pad=")
|
||
decrease_count = fc.count("force_original_aspect_ratio=decrease")
|
||
assert pad_count >= 3, f"3个 clip 都应有 pad,实际 {pad_count} 个"
|
||
assert decrease_count >= 3, f"3个 clip 都应用 decrease 模式,实际 {decrease_count} 个"
|
||
|
||
def test_video_fps_normalized(self):
|
||
"""[2/4] 视频帧率:所有 clip 都有 fps 滤镜。"""
|
||
clips = self._make_one_take_clips()
|
||
asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)}
|
||
svc = _make_service(clips, asset_paths, output_fps=30)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=10.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
fps_count = fc.count("fps=30")
|
||
assert fps_count >= 3, f"3个 clip 都应有 fps=30,实际 {fps_count} 个"
|
||
|
||
def test_audio_format_normalized(self):
|
||
"""[3/4] 音频格式:3 个 main 原声 + 3 个独立音频轨都有 aformat 归一化。"""
|
||
clips = self._make_one_take_clips() + [
|
||
_make_clip("tts1", "main", order=10, duration=5.0, config={"role": "audio"}),
|
||
_make_clip("tts2", "main", order=11, duration=4.0, config={"role": "audio"}),
|
||
_make_clip("tts3", "main", order=12, duration=3.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)}
|
||
asset_paths.update({f"asset_tts{i}.mp4": Path(f"/tmp/asset_tts{i}.mp4") for i in range(1, 4)})
|
||
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()
|
||
mix_audio(ctx, layers, 12.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
|
||
aformat_count = cmd_str.count("aformat=")
|
||
assert aformat_count >= 6, f"6 路音频(3原声+3独立轨)都应有 aformat,实际 {aformat_count} 个"
|
||
assert "sample_rates=48000" in cmd_str
|
||
assert "channel_layouts=stereo" in cmd_str
|
||
assert "sample_fmts=fltp" in cmd_str
|
||
# main 图层原声参与
|
||
for i in range(1, 4):
|
||
assert f"asset_c{i}.mp4" in cmd_str
|
||
|
||
def test_audio_codec_aac(self):
|
||
"""[4/4] 音频编码:输出为 aac(仅独立音频轨参与)。"""
|
||
clips = self._make_one_take_clips() + [
|
||
_make_clip("tts1", "main", order=10, duration=5.0, config={"role": "audio"}),
|
||
_make_clip("tts2", "main", order=11, duration=4.0, config={"role": "audio"}),
|
||
_make_clip("tts3", "main", order=12, duration=3.0, config={"role": "audio"}),
|
||
]
|
||
asset_paths = {f"asset_c{i}.mp4": Path(f"/tmp/asset_c{i}.mp4") for i in range(1, 4)}
|
||
asset_paths.update({f"asset_tts{i}.mp4": Path(f"/tmp/asset_tts{i}.mp4") for i in range(1, 4)})
|
||
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()
|
||
mix_audio(ctx, layers, 12.0)
|
||
|
||
assert mock_run.called
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "aac" in cmd
|
||
# 3 个 main 原声 concat 后再与独立轨 amix
|
||
assert "concat=n=3:v=0:a=1" in " ".join(cmd)
|
||
|
||
|
||
class TestVoiceoverTopLevelConfigBridge:
|
||
"""顶层 voice_id + custom_text 桥接到 tts 配置的兼容性测试.
|
||
|
||
前端一键生成页面传 config.voice_id + config.custom_text(顶层字段),
|
||
统一渲染引擎从 config.tts 读取。桥接逻辑确保两条路径都能工作。
|
||
"""
|
||
|
||
def test_top_level_voice_id_with_text_triggers_tts(self):
|
||
"""顶层 voice_id + custom_text 能触发 TTS 配音(桥接生效)。"""
|
||
from unittest.mock import MagicMock
|
||
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_tts_001",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"custom_text": "大家好,欢迎来到我的频道",
|
||
},
|
||
)
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts"),
|
||
)
|
||
|
||
mock_seg = MagicMock()
|
||
mock_seg.audio_path = Path("/tmp/test_tts/tts/voiceover_full.wav")
|
||
mock_seg.start_time = 0.0
|
||
mock_seg.duration = 3.0
|
||
mock_result = MagicMock()
|
||
mock_result.success = True
|
||
mock_result.segments = [mock_seg]
|
||
mock_result.total_duration = 3.0
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch(
|
||
"video_processing.tts_engine.TtsEngine.generate_full_voiceover",
|
||
return_value=mock_result,
|
||
),
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0)
|
||
|
||
assert result is True, "顶层 voice_id + custom_text 应触发 TTS 配音"
|
||
# 应有 audio 图层
|
||
audio_layer = next((layer for layer in layers if layer.role == "audio"), None)
|
||
assert audio_layer is not None, "应添加 audio 图层"
|
||
assert len(audio_layer.clips) == 1, "应有 1 个配音片段"
|
||
|
||
def test_tts_config_takes_priority(self):
|
||
"""config.tts.enabled 已配置时,以 tts 配置为准,不触发桥接。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
# tts.enabled=True 但 text 为空(应失败),顶层有 text
|
||
plan = FakePlan(
|
||
id="plan_tts_002",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"custom_text": "顶层文本不生效",
|
||
"tts": {
|
||
"enabled": True,
|
||
"voice_id": "longxiaochun_v3",
|
||
"text": "", # tts 配置里 text 为空
|
||
},
|
||
},
|
||
)
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts"),
|
||
)
|
||
|
||
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)
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0)
|
||
|
||
# tts.enabled=True 但 text 为空 → 生成失败 → 返回 False
|
||
# 关键是不触发桥接(不会用顶层的 custom_text)
|
||
assert result is False
|
||
|
||
def test_top_level_voice_id_without_text_no_trigger(self):
|
||
"""只有 voice_id 没有 custom_text 不触发 TTS 配音。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_tts_003",
|
||
config={"voice_id": "longxiaoxia_v3", "custom_text": ""},
|
||
)
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts"),
|
||
)
|
||
|
||
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)
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0)
|
||
|
||
assert result is False
|
||
|
||
def test_no_voice_config_no_trigger(self):
|
||
"""没有 voice_id 也没有 tts 配置时,不触发配音。"""
|
||
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)
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=5.0)
|
||
|
||
assert result is False
|
||
# 没有 audio 图层
|
||
assert not any(layer.role == "audio" for layer in layers)
|
||
|
||
|
||
class TestVoiceoverSubtitleAlign:
|
||
"""预设配音 + 自动字幕 → 字幕对齐 TTS 配音.
|
||
|
||
前端预设配音模式只传 voice_id,不传 custom_text。
|
||
配合自动字幕时,用 ASR 识别结果生成逐字幕配音。
|
||
"""
|
||
|
||
def _make_mock_timeline(self, segments_data):
|
||
"""构造模拟的 SubtitleTimeline."""
|
||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
||
|
||
segments = [SubtitleSegment(text=s["text"], start=s["start"], end=s["end"]) for s in segments_data]
|
||
return SubtitleTimeline(segments=segments, total_duration=10.0)
|
||
|
||
def test_preset_voice_with_auto_subtitle_triggers_tts(self):
|
||
"""预设配音 + 自动字幕 → 触发字幕对齐 TTS 配音。"""
|
||
from unittest.mock import MagicMock, PropertyMock
|
||
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_sub_001",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"subtitle": {
|
||
"enabled": True,
|
||
"auto_generated": True,
|
||
},
|
||
},
|
||
)
|
||
|
||
mock_asr = MagicMock()
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts_sub"),
|
||
asr_service=mock_asr,
|
||
)
|
||
|
||
# 模拟 ASR 结果(通过缓存注入)
|
||
svc._asr_timeline_cache = self._make_mock_timeline(
|
||
[
|
||
{"text": "大家好欢迎来到我的频道", "start": 0.0, "end": 2.5},
|
||
{"text": "今天给大家分享一个小技巧", "start": 2.5, "end": 5.0},
|
||
{"text": "记得点赞关注哦", "start": 5.0, "end": 7.0},
|
||
]
|
||
)
|
||
svc._asr_timeline_cached = True
|
||
|
||
with _patch_path_exists():
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
with patch(
|
||
"video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover",
|
||
) as mock_sub_vo:
|
||
# 构造模拟返回
|
||
mock_seg1 = MagicMock()
|
||
mock_seg1.audio_path = Path("/tmp/tts/seg_000.wav")
|
||
mock_seg1.start_time = 0.0
|
||
mock_seg1.duration = 2.5
|
||
mock_seg2 = MagicMock()
|
||
mock_seg2.audio_path = Path("/tmp/tts/seg_001.wav")
|
||
mock_seg2.start_time = 2.5
|
||
mock_seg2.duration = 2.5
|
||
mock_result = MagicMock()
|
||
mock_result.success = True
|
||
mock_result.segments = [mock_seg1, mock_seg2]
|
||
mock_result.total_duration = 5.0
|
||
mock_sub_vo.return_value = mock_result
|
||
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||
|
||
assert result is True, "预设配音+自动字幕应触发 TTS 配音"
|
||
# 应调用字幕对齐模式
|
||
mock_sub_vo.assert_called_once()
|
||
# 应有 audio 图层
|
||
audio_layer = next((layer for layer in layers if layer.role == "audio"), None)
|
||
assert audio_layer is not None
|
||
assert len(audio_layer.clips) == 2 # 2 个字幕对应 2 段配音
|
||
|
||
def test_preset_voice_without_auto_subtitle_no_trigger(self):
|
||
"""只有 voice_id 没有自动字幕 → 不触发配音。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_sub_002",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"subtitle": {"enabled": True, "auto_generated": False},
|
||
},
|
||
)
|
||
mock_asr = MagicMock()
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts_sub"),
|
||
asr_service=mock_asr,
|
||
)
|
||
|
||
with _patch_path_exists():
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||
|
||
assert result is False
|
||
assert not any(layer.role == "audio" for layer in layers)
|
||
|
||
def test_preset_voice_no_asr_service_no_trigger(self):
|
||
"""有 voice_id + 自动字幕但没有 ASR 服务 → 不触发。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_sub_003",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"subtitle": {"enabled": True, "auto_generated": True},
|
||
},
|
||
)
|
||
# 不传 asr_service
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts_sub"),
|
||
)
|
||
|
||
with _patch_path_exists():
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||
|
||
assert result is False
|
||
|
||
def test_preset_voice_asr_empty_segments_skip(self):
|
||
"""ASR 无识别结果 → 跳过配音,不报错。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_sub_004",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"subtitle": {"enabled": True, "auto_generated": True},
|
||
},
|
||
)
|
||
mock_asr = MagicMock()
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts_sub"),
|
||
asr_service=mock_asr,
|
||
)
|
||
|
||
# ASR 返回空结果
|
||
svc._asr_timeline_cache = self._make_mock_timeline([])
|
||
svc._asr_timeline_cached = True
|
||
|
||
with _patch_path_exists():
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
# 不抛异常,返回 False 即可
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||
|
||
assert result is False
|
||
|
||
def test_asr_cache_reuse_between_subtitle_and_voiceover(self):
|
||
"""ASR 结果缓存:字幕和配音共用一次 ASR 调用。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_sub_005",
|
||
config={
|
||
"voice_id": "longxiaoxia_v3",
|
||
"subtitle": {"enabled": True, "auto_generated": True},
|
||
},
|
||
)
|
||
mock_asr = MagicMock()
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts_sub"),
|
||
asr_service=mock_asr,
|
||
)
|
||
|
||
# 先模拟调用过一次 ASR(比如字幕模块先调用)
|
||
svc._asr_timeline_cache = self._make_mock_timeline(
|
||
[
|
||
{"text": "测试字幕", "start": 0.0, "end": 2.0},
|
||
]
|
||
)
|
||
svc._asr_timeline_cached = True
|
||
|
||
with _patch_path_exists():
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
with patch(
|
||
"video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover",
|
||
) as mock_sub_vo:
|
||
mock_seg = MagicMock()
|
||
mock_seg.audio_path = Path("/tmp/tts/seg_000.wav")
|
||
mock_seg.start_time = 0.0
|
||
mock_seg.duration = 2.0
|
||
mock_result = MagicMock()
|
||
mock_result.success = True
|
||
mock_result.segments = [mock_seg]
|
||
mock_result.total_duration = 2.0
|
||
mock_sub_vo.return_value = mock_result
|
||
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||
|
||
assert result is True
|
||
# ASR 服务不应被再次调用(使用缓存)
|
||
mock_asr.transcribe.assert_not_called()
|
||
|
||
def test_tts_config_align_mode_subtitle_also_works(self):
|
||
"""标准 tts 配置 + align_mode=subtitle 也走字幕对齐模式。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
plan = FakePlan(
|
||
id="plan_sub_006",
|
||
config={
|
||
"tts": {
|
||
"enabled": True,
|
||
"voice_id": "longxiaoxia_v3",
|
||
"text": "",
|
||
"align_mode": "subtitle",
|
||
},
|
||
"subtitle": {"enabled": True, "auto_generated": True},
|
||
},
|
||
)
|
||
mock_asr = MagicMock()
|
||
svc = UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=Path("/tmp/test_tts_sub"),
|
||
asr_service=mock_asr,
|
||
)
|
||
|
||
svc._asr_timeline_cache = self._make_mock_timeline(
|
||
[
|
||
{"text": "字幕1", "start": 0.0, "end": 3.0},
|
||
{"text": "字幕2", "start": 3.0, "end": 6.0},
|
||
]
|
||
)
|
||
svc._asr_timeline_cached = True
|
||
|
||
with _patch_path_exists():
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
with patch(
|
||
"video_processing.tts_engine.TtsEngine.generate_subtitle_voiceover",
|
||
) as mock_sub_vo:
|
||
mock_seg = MagicMock()
|
||
mock_seg.audio_path = Path("/tmp/tts/seg_000.wav")
|
||
mock_seg.start_time = 0.0
|
||
mock_seg.duration = 6.0
|
||
mock_result = MagicMock()
|
||
mock_result.success = True
|
||
mock_result.segments = [mock_seg, mock_seg]
|
||
mock_result.total_duration = 6.0
|
||
mock_sub_vo.return_value = mock_result
|
||
|
||
result = svc._maybe_add_voiceover_layer(layers, video_duration=10.0)
|
||
|
||
assert result is True
|
||
mock_sub_vo.assert_called_once()
|