6f0a8253f6
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m41s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
P1-1: processor.py 使用了 logger 但未定义,补 import logging + getLogger
P1-2: test_unified_render_service.py 导入路径同步:
- 音频函数 (mix_audio/merge_audio_video/clip_has_audio/RenderContext) → render_audio
- 字幕函数 (_hex_to_ass_color/_position_to_ass_alignment/generate_ass_subtitles) → render_subtitles
- patch.object(svc, '_mix_audio') → patch('video_processing.unified_render_service.mix_audio')
- patch.object(svc, '_merge_audio_video') → patch('video_processing.unified_render_service.merge_audio_video')
- probe_has_audio/run_ffmpeg patch 路径对齐到 render_audio 模块
全部 84 个测试通过。
1663 lines
64 KiB
Python
Executable File
1663 lines
64 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"
|
||
status: str = "ready"
|
||
config: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
@dataclass
|
||
class FakePlan:
|
||
"""模拟 EditPlan。"""
|
||
|
||
id: str = "plan_001"
|
||
name: str = "测试计划"
|
||
config: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
def _make_clip(
|
||
clip_id: str,
|
||
clip_type: str = "main",
|
||
order: int = 0,
|
||
asset_id: str = "",
|
||
duration: float = 0.0,
|
||
transition_effect: str = "cut",
|
||
config: dict[str, Any] | None = None,
|
||
) -> FakeClip:
|
||
return FakeClip(
|
||
id=clip_id,
|
||
clip_type=clip_type,
|
||
order=order,
|
||
asset_id=asset_id or f"asset_{clip_id}.mp4",
|
||
duration=duration,
|
||
transition_effect=transition_effect,
|
||
config=config or {},
|
||
)
|
||
|
||
|
||
def _make_service(
|
||
clips: list[FakeClip] | None = None,
|
||
asset_paths: dict[str, Path] | None = None,
|
||
work_dir: Path | None = None,
|
||
) -> UnifiedRenderService:
|
||
"""创建测试用的 UnifiedRenderService 实例。
|
||
|
||
如果未提供 asset_paths,自动从 clips 生成默认映射
|
||
(asset_id → /tmp/asset_{clip_id}.mp4)。
|
||
"""
|
||
plan = FakePlan()
|
||
clips = clips or []
|
||
work_dir = work_dir or Path("/tmp/test_render")
|
||
if asset_paths is None:
|
||
asset_paths = {}
|
||
for c in clips:
|
||
if c.asset_id:
|
||
asset_paths[c.asset_id] = Path(f"/tmp/{c.asset_id}")
|
||
return UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=work_dir,
|
||
)
|
||
|
||
|
||
def _patch_path_exists():
|
||
"""Patch Path.exists() 让测试路径返回 True。"""
|
||
return patch("pathlib.Path.exists", return_value=True)
|
||
|
||
|
||
def _make_ctx() -> RenderContext:
|
||
"""创建测试用 RenderContext。"""
|
||
return RenderContext(work_dir=Path("/tmp/test_render"), plan_id="plan_001")
|
||
|
||
|
||
# ── 测试 _resolve_layer_role ─────────────────────────────────────────────────
|
||
|
||
|
||
class TestResolveLayerRole:
|
||
"""测试 clip_type → layer role 映射。"""
|
||
|
||
def test_main_default(self):
|
||
assert _resolve_layer_role("main", {}) == "main"
|
||
|
||
def test_main_with_b_roll_role(self):
|
||
assert _resolve_layer_role("main", {"role": "b_roll"}) == "broll"
|
||
|
||
def test_overlay(self):
|
||
assert _resolve_layer_role("overlay", {}) == "overlay"
|
||
|
||
def test_background(self):
|
||
assert _resolve_layer_role("background", {}) == "background"
|
||
|
||
def test_corner_voice(self):
|
||
assert _resolve_layer_role("corner_voice", {}) == "corner_voice"
|
||
|
||
def test_b_roll(self):
|
||
assert _resolve_layer_role("b_roll", {}) == "broll"
|
||
|
||
def test_intro(self):
|
||
assert _resolve_layer_role("intro", {}) == "main"
|
||
|
||
def test_outro(self):
|
||
assert _resolve_layer_role("outro", {}) == "main"
|
||
|
||
|
||
# ── 测试图层分组 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGroupClipsIntoLayers:
|
||
"""测试 _group_clips_into_layers 方法。"""
|
||
|
||
def test_group_clips_one_take(self):
|
||
"""4 个 main clips → 1 个 main_layer。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0),
|
||
_make_clip("c2", "main", order=1),
|
||
_make_clip("c3", "main", order=2),
|
||
_make_clip("c4", "main", order=3),
|
||
]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
|
||
assert len(layers) == 1
|
||
assert layers[0].role == "main"
|
||
assert len(layers[0].clips) == 4
|
||
assert layers[0].z_index == 0
|
||
|
||
def test_group_clips_pip(self):
|
||
"""1 main + 2 overlay → main_layer + overlay_layer。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0),
|
||
_make_clip("c2", "overlay", order=1),
|
||
_make_clip("c3", "overlay", order=2),
|
||
]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
|
||
roles = {lyr.role for lyr in layers}
|
||
assert "main" in roles
|
||
assert "overlay" in roles
|
||
|
||
main_layer = next(lyr for lyr in layers if lyr.role == "main")
|
||
overlay_layer = next(lyr for lyr in layers if lyr.role == "overlay")
|
||
assert len(main_layer.clips) == 1
|
||
assert len(overlay_layer.clips) == 2
|
||
assert overlay_layer.z_index > main_layer.z_index
|
||
|
||
def test_group_clips_voice_over(self):
|
||
"""3 个 main(b_roll) clips → 1 个 broll_layer。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, config={"role": "b_roll"}),
|
||
_make_clip("c2", "main", order=1, config={"role": "b_roll"}),
|
||
_make_clip("c3", "main", order=2, config={"role": "b_roll"}),
|
||
]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
|
||
assert len(layers) == 1
|
||
assert layers[0].role == "broll"
|
||
assert len(layers[0].clips) == 3
|
||
|
||
def test_group_clips_voice_pip(self):
|
||
"""1 background + 1 corner_voice + 2 b_roll → 3 layers。"""
|
||
clips = [
|
||
_make_clip("c1", "background", order=0),
|
||
_make_clip("c2", "corner_voice", order=1),
|
||
_make_clip("c3", "b_roll", order=2),
|
||
_make_clip("c4", "b_roll", order=3),
|
||
]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
|
||
roles = {lyr.role for lyr in layers}
|
||
assert roles == {"background", "corner_voice", "broll"}
|
||
assert len(layers) == 3
|
||
|
||
# z_index 排序
|
||
assert layers[0].z_index <= layers[1].z_index <= layers[2].z_index
|
||
|
||
def test_group_clips_intro_outro(self):
|
||
"""intro + 2 main + outro → 1 main_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 → xfade 串联。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=3.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, input_args = svc._build_filter_complex(layers)
|
||
|
||
assert input_args.count("-i") == 2
|
||
assert "xfade=" in fc
|
||
assert "[final_video]" in fc
|
||
|
||
def test_with_overlay(self):
|
||
"""main + overlay → overlay 滤镜。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0),
|
||
_make_clip("c2", "overlay", order=1),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, input_args = svc._build_filter_complex(layers)
|
||
|
||
assert "overlay=" in fc
|
||
assert "[final_video]" in fc
|
||
|
||
def test_setpts_before_fps_in_xfade_inputs(self):
|
||
"""多视频 xfade 模式:setpts=PTS-STARTPTS 必须在 fps 之前,确保 xfade 时各片段 PTS 一致。
|
||
|
||
构造两个不同时长的视频片段,验证生成的 filter_complex 中每个片段的
|
||
预处理滤镜链里 setpts 都在 fps 前面。
|
||
"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=5.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
# 确保 xfade 存在
|
||
assert "xfade=" in fc
|
||
|
||
# 提取每个 clip 的预处理滤镜链([i:v]...[vi] 部分)
|
||
# 验证:每个 clip 滤镜链中,setpts=PTS-STARTPTS 的最后一次出现
|
||
# 必须在 fps= 的前面(PTS 归一化后再统一帧率)
|
||
import re
|
||
|
||
clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]")
|
||
matches = clip_pattern.findall(fc)
|
||
assert len(matches) == 2, f"Expected 2 clip preprocessing chains, got {len(matches)}"
|
||
|
||
for idx, chain_str in matches:
|
||
# 找到所有 setpts 和 fps 的位置
|
||
setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)]
|
||
fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)]
|
||
|
||
assert setpts_positions, f"clip {idx}: 未找到 setpts=PTS-STARTPTS"
|
||
assert fps_positions, f"clip {idx}: 未找到 fps="
|
||
|
||
# 最后一个 setpts 必须在第一个 fps 之前
|
||
last_setpts = max(setpts_positions)
|
||
first_fps = min(fps_positions)
|
||
assert last_setpts < first_fps, (
|
||
f"clip {idx}: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。"
|
||
f"滤镜链: {chain_str}"
|
||
)
|
||
|
||
def test_setpts_before_fps_single_clip(self):
|
||
"""单视频模式(一镜到底):setpts 也必须在 fps 之前。
|
||
|
||
单视频虽然没有 xfade,但滤镜链顺序应保持一致,确保 PTS 处理逻辑统一。
|
||
"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
import re
|
||
|
||
clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]")
|
||
matches = clip_pattern.findall(fc)
|
||
assert len(matches) == 1
|
||
|
||
chain_str = matches[0][1]
|
||
setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)]
|
||
fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)]
|
||
|
||
assert setpts_positions, "单视频: 未找到 setpts=PTS-STARTPTS"
|
||
assert fps_positions, "单视频: 未找到 fps="
|
||
|
||
last_setpts = max(setpts_positions)
|
||
first_fps = min(fps_positions)
|
||
assert last_setpts < first_fps, (
|
||
f"单视频: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" f"滤镜链: {chain_str}"
|
||
)
|
||
|
||
def test_main_clip_uses_fill_crop_strategy(self):
|
||
"""main/broll clip 使用铺满裁剪策略(scale increase + crop),不是等比+黑边。
|
||
|
||
对齐链路A编辑器合成行为,与主流短视频平台一致。
|
||
"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
# 验证:scale 使用 force_original_aspect_ratio=increase(铺满)
|
||
assert "force_original_aspect_ratio=increase" in fc
|
||
# 验证:有 crop(居中裁剪)
|
||
assert "crop=1280:720" in fc
|
||
# 验证:没有 pad(不是黑边模式)
|
||
assert "pad=" not in fc
|
||
|
||
def test_broll_clip_uses_fill_crop_strategy(self):
|
||
"""broll clip 同样使用铺满裁剪策略。"""
|
||
clips = [_make_clip("c1", "b_roll", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
assert "force_original_aspect_ratio=increase" in fc
|
||
assert "crop=1280:720" in fc
|
||
assert "pad=" not in fc
|
||
|
||
def test_background_uses_fill_crop_strategy(self):
|
||
"""background 层也使用铺满裁剪(已有的行为,保持一致)。"""
|
||
clips = [_make_clip("c1", "background", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
|
||
assert "force_original_aspect_ratio=increase" in fc
|
||
assert "crop=1280:720" in fc
|
||
|
||
def test_empty_layers_raises(self):
|
||
"""空图层列表抛出 ValueError。"""
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="没有可渲染的图层"):
|
||
svc._build_filter_complex([])
|
||
|
||
|
||
class TestPassThrough:
|
||
"""测试单图层单 clip 直通优化路径。"""
|
||
|
||
def test_can_use_pass_through_single_main_clip(self):
|
||
"""1个main图层 + 1个clip → 可以直通。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
assert svc._can_use_pass_through(layers) is True
|
||
|
||
def test_can_use_pass_through_single_broll_clip(self):
|
||
"""1个broll图层 + 1个clip → 可以直通。"""
|
||
clips = [_make_clip("c1", "b_roll", order=0, duration=5.0)]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
assert svc._can_use_pass_through(layers) is True
|
||
|
||
def test_can_use_pass_through_single_background_clip(self):
|
||
"""1个background图层 + 1个clip → 可以直通。"""
|
||
clips = [_make_clip("c1", "background", order=0, duration=5.0)]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
assert svc._can_use_pass_through(layers) is True
|
||
|
||
def test_cannot_pass_through_multi_clips(self):
|
||
"""1个图层 + 多个clips → 不能直通(需要xfade)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=3.0),
|
||
]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
assert svc._can_use_pass_through(layers) is False
|
||
|
||
def test_cannot_pass_through_multi_layers(self):
|
||
"""多个图层 → 不能直通。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip("c2", "overlay", order=1, duration=5.0),
|
||
]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
assert svc._can_use_pass_through(layers) is False
|
||
|
||
def test_cannot_pass_through_overlay_only(self):
|
||
"""只有overlay图层 → 不能直通(需要叠加到主层)。"""
|
||
clips = [_make_clip("c1", "overlay", order=0, duration=5.0)]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
assert svc._can_use_pass_through(layers) is False
|
||
|
||
def test_render_uses_pass_through_for_single_clip(self):
|
||
"""单clip渲染时走直通路径(调用_render_pass_through而非_execute_ffmpeg)。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||
patch("video_processing.unified_render_service.mix_audio", return_value=None),
|
||
patch("shutil.copy2"),
|
||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
mock_pass.assert_called_once()
|
||
mock_exec.assert_not_called()
|
||
assert result.duration == 5.0
|
||
|
||
def test_render_uses_filter_complex_for_multi_clips(self):
|
||
"""多clip渲染时走完整filter_complex路径。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=3.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch.object(svc, "_render_pass_through") as mock_pass,
|
||
patch.object(svc, "_execute_ffmpeg") as mock_exec,
|
||
patch("video_processing.unified_render_service.mix_audio", return_value=None),
|
||
patch("shutil.copy2"),
|
||
patch.object(svc, "_probe_output", return_value=(5.5, 2048, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
mock_pass.assert_not_called()
|
||
mock_exec.assert_called_once()
|
||
assert result.duration == 5.5
|
||
|
||
|
||
# ── 测试 ASS 字幕生成 ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAssSubtitles:
|
||
"""测试 ASS 字幕生成功能。"""
|
||
|
||
def test_hex_to_ass_color_white(self):
|
||
"""#ffffff → &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):
|
||
"""单主clip时直接提取音频。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
assert result.name == "audio_plan_001.aac"
|
||
mock_run.assert_called_once()
|
||
# 验证命令包含 -vn(无视频)和 aac 编码
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "-vn" in cmd
|
||
assert "aac" in cmd
|
||
|
||
def test_mix_audio_multi_main_clips(self):
|
||
"""多主clip时用concat拼接音频。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=2.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 4.5)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
# 验证有 filter_complex 和 concat
|
||
assert "-filter_complex" in cmd
|
||
cmd_str = " ".join(cmd)
|
||
assert "concat=n=2:v=0:a=1" in cmd_str
|
||
|
||
def test_mix_audio_with_independent_audio_track(self):
|
||
"""有独立音频轨时用amix混音。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip(
|
||
"bgm1",
|
||
"main",
|
||
order=0,
|
||
duration=5.0,
|
||
config={"role": "audio", "volume": 0.5},
|
||
),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
assert "amix" in cmd_str
|
||
assert "volume=0.5" in cmd_str
|
||
|
||
def test_mix_audio_no_audio_returns_none(self):
|
||
"""没有音频素材时返回None。"""
|
||
# 构造一个没有音频的场景(比如纯文字)
|
||
clips = [_make_clip("t1", "title", order=0, duration=3.0)]
|
||
clips[0].asset_id = "" # 无素材
|
||
asset_paths: dict[str, Path] = {}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=3.0),
|
||
):
|
||
# 没有素材的clip会被跳过,layers为空
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 3.0)
|
||
|
||
assert result is None
|
||
|
||
def test_mix_audio_background_not_used_as_main(self):
|
||
"""background 图层不参与主音频,main 优先级更高。"""
|
||
clips = [
|
||
_make_clip("bg1", "background", order=0, duration=5.0),
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_bg1.mp4": Path("/tmp/asset_bg1.mp4"),
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
# 验证主音频源是 main 的 c1,不是 background 的 bg1
|
||
# 单 main clip 走直接提取路径,输入文件应该只有 c1
|
||
cmd_str = " ".join(cmd)
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_bg1.mp4" not in cmd_str
|
||
|
||
def test_mix_audio_main_priority_over_broll(self):
|
||
"""main 图层优先级高于 broll。"""
|
||
clips = [
|
||
_make_clip("b1", "b_roll", order=0, duration=5.0),
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_b1.mp4": Path("/tmp/asset_b1.mp4"),
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
# 主音频源应该是 main 的 c1,不是 broll 的 b1
|
||
assert "asset_c1.mp4" in cmd_str
|
||
assert "asset_b1.mp4" not in cmd_str
|
||
|
||
def test_mix_audio_broll_used_when_no_main(self):
|
||
"""没有 main 时,broll 作为主音频源。"""
|
||
clips = [_make_clip("b1", "b_roll", order=0, duration=5.0)]
|
||
asset_paths = {"asset_b1.mp4": Path("/tmp/asset_b1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "-vn" in cmd
|
||
assert "asset_b1.mp4" in " ".join(cmd)
|
||
|
||
def test_mix_audio_single_clip_truncated_to_video_duration(self):
|
||
"""单clip音频截断到 video_duration(video_duration < clip有效时长)。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=10.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=10.0),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
# video_duration 只有 3.0,小于 clip 的 10.0
|
||
result = mix_audio(ctx, layers, 3.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
# 验证 -t 参数是 3.0 不是 10.0
|
||
t_index = cmd.index("-t")
|
||
assert t_index >= 0
|
||
t_value = float(cmd[t_index + 1])
|
||
assert t_value == 3.0
|
||
|
||
def test_merge_audio_video(self):
|
||
"""合并音视频命令正确。"""
|
||
video_path = Path("/tmp/video.mp4")
|
||
audio_path = Path("/tmp/audio.aac")
|
||
output_path = Path("/tmp/output.mp4")
|
||
ctx = _make_ctx()
|
||
|
||
with patch("video_processing.render_audio.run_ffmpeg") as mock_run:
|
||
merge_audio_video(ctx, video_path, audio_path, output_path)
|
||
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "-c:v" in cmd
|
||
assert "copy" in cmd
|
||
assert "-map" in cmd
|
||
assert "-shortest" in cmd
|
||
|
||
def test_render_calls_audio_mixing(self):
|
||
"""多clip完整render流程会调用音频混音(非直通路径)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=2.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch.object(svc, "_execute_ffmpeg"),
|
||
patch("video_processing.unified_render_service.mix_audio", return_value=Path("/tmp/audio.aac")) as mock_mix,
|
||
patch("video_processing.unified_render_service.merge_audio_video") as mock_merge,
|
||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
mock_mix.assert_called_once()
|
||
mock_merge.assert_called_once()
|
||
assert result.duration == 5.0
|
||
|
||
def test_render_without_audio_copies_video(self):
|
||
"""多clip无音频时走copy路径(非直通路径)。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0),
|
||
_make_clip("c2", "main", order=1, duration=2.0),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch.object(svc, "_execute_ffmpeg"),
|
||
patch("video_processing.unified_render_service.mix_audio", return_value=None),
|
||
patch("shutil.copy2") as mock_copy,
|
||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
mock_copy.assert_called_once()
|
||
assert result.duration == 5.0
|
||
|
||
def test_render_pass_through_skips_audio_mix(self):
|
||
"""直通场景下视频+音频一次完成,跳过 _mix_audio 和 _merge_audio_video。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch.object(svc, "_render_pass_through", return_value=True) as mock_pt,
|
||
patch("video_processing.unified_render_service.mix_audio") as mock_mix,
|
||
patch("video_processing.unified_render_service.merge_audio_video") as mock_merge,
|
||
patch("shutil.copy2") as mock_copy,
|
||
patch.object(svc, "_probe_output", return_value=(5.0, 1024, 1280, 720)),
|
||
):
|
||
result = svc.render()
|
||
|
||
# 直通场景调用了 _render_pass_through,跳过了 _mix_audio / _merge / copy
|
||
mock_pt.assert_called_once()
|
||
mock_mix.assert_not_called()
|
||
mock_merge.assert_not_called()
|
||
mock_copy.assert_not_called()
|
||
assert result.duration == 5.0
|
||
|
||
def test_pass_through_main_has_aac_audio(self):
|
||
"""直通main/broll场景输出带aac音频。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
result = svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=5.0)
|
||
|
||
assert result is True # main 类型返回有音频
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "-an" not in cmd # 不再是无声
|
||
assert "aac" in cmd # 有aac音频编码
|
||
assert "-b:a" in cmd
|
||
|
||
def test_pass_through_background_no_audio(self):
|
||
"""直通background场景不带音频(图片素材)。"""
|
||
clips = [_make_clip("bg1", "background", order=0, duration=5.0)]
|
||
asset_paths = {"asset_bg1.mp4": Path("/tmp/asset_bg1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
result = svc._render_pass_through(layers, Path("/tmp/out.mp4"))
|
||
|
||
assert result is False # background 返回无音频
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "aac" not in cmd # 没有音频编码参数
|
||
|
||
# ── 无音轨视频防御测试 ──
|
||
|
||
def test_mix_audio_main_no_audio_stream_returns_none(self):
|
||
"""主图层clip无音频流且无独立音频轨时,返回None(不报错)。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.probe_has_audio", return_value=False),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is None
|
||
# 没有音频流时不应调用 FFmpeg
|
||
mock_run.assert_not_called()
|
||
|
||
def test_mix_audio_partial_clips_no_audio_filtered(self):
|
||
"""部分主图层clip无音频流时,过滤掉无音轨的,剩余有音频的正常concat。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=3.0), # 无音频
|
||
_make_clip("c2", "main", order=1, duration=2.0), # 有音频
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
# 模拟:c1 无音频,c2 有音频
|
||
def fake_has_audio(path):
|
||
return "c2" in str(path)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.probe_has_audio", side_effect=fake_has_audio),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
# 只剩 1 个有效音频 clip,走单clip路径(-vn),不走 filter_complex concat
|
||
assert "-vn" in cmd
|
||
assert "concat=n=2" not in cmd_str
|
||
|
||
def test_mix_audio_all_main_no_audio_but_independent_track(self):
|
||
"""主图层全部无音频,但有独立音频轨时,正常走amix混音。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0), # 无音频
|
||
_make_clip(
|
||
"bgm1",
|
||
"main",
|
||
order=0,
|
||
duration=5.0,
|
||
config={"role": "audio", "volume": 0.5},
|
||
), # 独立音频轨(有音频)
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
def fake_has_audio(path):
|
||
return "bgm" in str(path)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.probe_has_audio", side_effect=fake_has_audio),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is not None
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
cmd_str = " ".join(cmd)
|
||
# 只有独立音频轨参与混音,amix 输入数=1
|
||
assert "amix=inputs=1" in cmd_str
|
||
|
||
def test_mix_audio_both_no_audio_returns_none(self):
|
||
"""主图层和独立音频轨都无音频时,返回None。"""
|
||
clips = [
|
||
_make_clip("c1", "main", order=0, duration=5.0),
|
||
_make_clip(
|
||
"bgm1",
|
||
"main",
|
||
order=0,
|
||
duration=5.0,
|
||
config={"role": "audio"},
|
||
),
|
||
]
|
||
asset_paths = {
|
||
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
|
||
"asset_bgm1.mp4": Path("/tmp/asset_bgm1.mp4"),
|
||
}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch("video_processing.render_audio.probe_has_audio", return_value=False),
|
||
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
|
||
):
|
||
layers = svc._group_clips_into_layers(svc._resolve_clips())
|
||
ctx = _make_ctx()
|
||
result = mix_audio(ctx, layers, 5.0)
|
||
|
||
assert result is None
|
||
mock_run.assert_not_called()
|
||
|
||
def test_clip_has_audio_cache(self):
|
||
"""_clip_has_audio 带缓存,同一clip只探测一次。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")}
|
||
svc = _make_service(clips, asset_paths)
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
):
|
||
resolved = svc._resolve_clips()
|
||
clip = resolved[0]
|
||
|
||
with patch("video_processing.render_audio.probe_has_audio", return_value=True) as mock_probe:
|
||
# 调用 3 次
|
||
ctx = _make_ctx()
|
||
r1 = clip_has_audio(ctx, clip)
|
||
r2 = clip_has_audio(ctx, clip)
|
||
r3 = clip_has_audio(ctx, clip)
|
||
|
||
assert r1 is True and r2 is True and r3 is True
|
||
# 实际只探测了 1 次
|
||
assert mock_probe.call_count == 1
|
||
|
||
|
||
# ── 测试 stream copy 流拷贝优化 ───────────────────────────────────────────────
|
||
|
||
|
||
class TestStreamCopy:
|
||
"""stream copy 流拷贝优化测试。"""
|
||
|
||
def _make_single_clip_service(self):
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
svc = _make_service(clips)
|
||
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
|
||
resolved = svc._resolve_clips()
|
||
layers = svc._group_clips_into_layers(resolved)
|
||
return svc, resolved[0], layers
|
||
|
||
def test_can_use_stream_copy_all_conditions_met(self):
|
||
"""所有条件满足 → 可以 stream copy。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
with patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
):
|
||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||
assert can_copy is True
|
||
assert "所有条件满足" in reason
|
||
|
||
def test_cannot_copy_with_subtitles(self):
|
||
"""有字幕 → 不能 stream copy。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=Path("/tmp/sub.ass"), video_duration=0)
|
||
assert can_copy is False
|
||
assert "字幕" in reason
|
||
|
||
def test_cannot_copy_wrong_codec(self):
|
||
"""编码不是 h264 → 不能 stream copy。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"video_codec": "hevc",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
with patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
):
|
||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||
assert can_copy is False
|
||
assert "编码" in reason
|
||
|
||
def test_cannot_copy_wrong_resolution(self):
|
||
"""分辨率不匹配 → 不能 stream copy。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1920,
|
||
"height": 1080,
|
||
"fps": 25.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
with patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
):
|
||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||
assert can_copy is False
|
||
assert "分辨率" in reason
|
||
|
||
def test_cannot_copy_wrong_fps(self):
|
||
"""帧率不匹配 → 不能 stream copy。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 30.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
with patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
):
|
||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||
assert can_copy is False
|
||
assert "帧率" in reason
|
||
|
||
def test_cannot_copy_wrong_pix_fmt(self):
|
||
"""像素格式不匹配 → 不能 stream copy。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv422p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
with patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
):
|
||
can_copy, reason = svc._can_use_stream_copy(clip, ass_path=None, video_duration=0)
|
||
assert can_copy is False
|
||
assert "像素格式" in reason
|
||
|
||
def test_try_render_stream_copy_success(self):
|
||
"""stream copy 渲染成功 → 返回 True。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
output_path = Path("/tmp/test_output.mp4")
|
||
|
||
def fake_stat():
|
||
m = MagicMock()
|
||
m.st_size = 1024000
|
||
return m
|
||
|
||
with (
|
||
patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
),
|
||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||
patch("pathlib.Path.exists", return_value=True),
|
||
patch("pathlib.Path.stat", side_effect=fake_stat),
|
||
):
|
||
result = svc._try_render_stream_copy(layers, output_path, ass_path=None, video_duration=0)
|
||
|
||
assert result is True
|
||
mock_run.assert_called_once()
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "-c:v" in cmd
|
||
assert "copy" in cmd
|
||
assert "-c:a" in cmd
|
||
|
||
def test_try_render_stream_copy_fallback_on_ffmpeg_error(self):
|
||
"""stream copy FFmpeg 失败 → 返回 False(调用方回退到重编码)。"""
|
||
svc, clip, layers = self._make_single_clip_service()
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
output_path = Path("/tmp/test_output.mp4")
|
||
|
||
import subprocess as sp
|
||
|
||
with (
|
||
patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
),
|
||
patch(
|
||
"video_processing.unified_render_service.run_ffmpeg",
|
||
side_effect=sp.CalledProcessError(1, ["ffmpeg"], stderr="copy failed"),
|
||
),
|
||
patch("pathlib.Path.exists", return_value=False),
|
||
):
|
||
result = svc._try_render_stream_copy(layers, output_path, ass_path=None, video_duration=0)
|
||
|
||
assert result is False
|
||
|
||
def test_render_uses_stream_copy_when_eligible(self):
|
||
"""完整渲染流程:满足条件时走 stream copy。"""
|
||
clips = [_make_clip("c1", "main", order=0, duration=5.0)]
|
||
svc = _make_service(clips)
|
||
|
||
probe_result = {
|
||
"width": 1280,
|
||
"height": 720,
|
||
"fps": 25.0,
|
||
"video_codec": "h264",
|
||
"pix_fmt": "yuv420p",
|
||
"duration": 5.0,
|
||
"has_audio": True,
|
||
"audio_codec": "aac",
|
||
}
|
||
|
||
def fake_stat():
|
||
m = MagicMock()
|
||
m.st_size = 1024000
|
||
return m
|
||
|
||
with (
|
||
_patch_path_exists(),
|
||
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
|
||
patch(
|
||
"video_processing.unified_render_service.probe_video_info",
|
||
return_value=probe_result,
|
||
),
|
||
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
|
||
patch("pathlib.Path.stat", side_effect=fake_stat),
|
||
patch("shutil.copy2"),
|
||
):
|
||
result = svc.render()
|
||
|
||
assert mock_run.call_count == 1
|
||
cmd = mock_run.call_args[0][0]
|
||
assert "copy" in cmd
|
||
assert isinstance(result.output_path, Path)
|