test(wave135): 视频拼接引擎纯逻辑抽离 + 70单测 #1049
+493
@@ -0,0 +1,493 @@
|
||||
"""视频拼接引擎纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 帧率解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_fps(fps_value: Any) -> float:
|
||||
"""解析帧率字符串/数值.
|
||||
|
||||
支持格式:
|
||||
- 数字: 30 → 30.0
|
||||
- 分数: "30/1" → 30.0, "24000/1001" → 23.976...
|
||||
- 字符串数字: "30" → 30.0
|
||||
|
||||
Args:
|
||||
fps_value: 帧率值(字符串、数字等)
|
||||
|
||||
Returns:
|
||||
帧率(fps),失败返回 30.0
|
||||
"""
|
||||
if fps_value is None:
|
||||
return 30.0
|
||||
|
||||
try:
|
||||
fps_str = str(fps_value).strip()
|
||||
if not fps_str:
|
||||
return 30.0
|
||||
|
||||
if "/" in fps_str:
|
||||
num_str, den_str = fps_str.split("/", 1)
|
||||
num = float(num_str)
|
||||
den = float(den_str)
|
||||
if den == 0:
|
||||
return 30.0
|
||||
return num / den
|
||||
|
||||
return float(fps_str)
|
||||
except (ValueError, TypeError, ZeroDivisionError):
|
||||
return 30.0
|
||||
|
||||
|
||||
def format_fps_filter(fps: float) -> str:
|
||||
"""格式化 fps 滤镜参数.
|
||||
|
||||
Args:
|
||||
fps: 帧率
|
||||
|
||||
Returns:
|
||||
fps 滤镜字符串
|
||||
"""
|
||||
# 接近整数时用整数形式
|
||||
if abs(fps - round(fps)) < 0.001:
|
||||
return f"fps={int(fps)}"
|
||||
return f"fps={fps:.3f}"
|
||||
|
||||
|
||||
# ── 输出参数计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_output_params(
|
||||
config_width: int,
|
||||
config_height: int,
|
||||
config_fps: float,
|
||||
first_video_info: Optional[dict] = None,
|
||||
default_width: int = 1080,
|
||||
default_height: int = 1920,
|
||||
default_fps: float = 30.0,
|
||||
) -> tuple[int, int, float]:
|
||||
"""计算输出视频参数.
|
||||
|
||||
优先级:
|
||||
1. config 中显式指定的(非 0 值)
|
||||
2. 第一段视频的探测参数
|
||||
3. 默认值
|
||||
|
||||
Args:
|
||||
config_width: 配置的宽度(0 表示未指定)
|
||||
config_height: 配置的高度(0 表示未指定)
|
||||
config_fps: 配置的帧率(0 表示未指定)
|
||||
first_video_info: 第一段视频的探测信息字典
|
||||
default_width: 默认宽度
|
||||
default_height: 默认高度
|
||||
default_fps: 默认帧率
|
||||
|
||||
Returns:
|
||||
(宽度, 高度, 帧率)
|
||||
"""
|
||||
width = config_width
|
||||
height = config_height
|
||||
fps = config_fps
|
||||
|
||||
info = first_video_info or {}
|
||||
|
||||
# 宽度:用配置 → 探测 → 默认
|
||||
if width == 0:
|
||||
width = int(info.get("width", default_width))
|
||||
|
||||
# 高度
|
||||
if height == 0:
|
||||
height = int(info.get("height", default_height))
|
||||
|
||||
# 帧率
|
||||
if fps == 0:
|
||||
fps_str = info.get("r_frame_rate", f"{int(default_fps)}/1")
|
||||
fps = parse_fps(fps_str)
|
||||
|
||||
# 确保都是有效值
|
||||
width = max(1, width)
|
||||
height = max(1, height)
|
||||
fps = max(1.0, fps)
|
||||
|
||||
return width, height, fps
|
||||
|
||||
|
||||
def calculate_scaled_size(
|
||||
src_w: int,
|
||||
src_h: int,
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""计算等比缩放后的尺寸和填充偏移.
|
||||
|
||||
保持宽高比,不足的部分用黑边填充。
|
||||
|
||||
Args:
|
||||
src_w: 原始宽度
|
||||
src_h: 原始高度
|
||||
target_w: 目标宽度
|
||||
target_h: 目标高度
|
||||
|
||||
Returns:
|
||||
(缩放后宽度, 缩放后高度, X偏移, Y偏移)
|
||||
"""
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return (target_w, target_h, 0, 0)
|
||||
|
||||
src_ratio = src_w / src_h
|
||||
target_ratio = target_w / target_h
|
||||
|
||||
if abs(src_ratio - target_ratio) < 0.001:
|
||||
# 比例相同,直接缩放
|
||||
return (target_w, target_h, 0, 0)
|
||||
elif src_ratio > target_ratio:
|
||||
# 源更宽,以宽度为准,上下填充
|
||||
scaled_w = target_w
|
||||
scaled_h = int(target_w / src_ratio)
|
||||
offset_x = 0
|
||||
offset_y = (target_h - scaled_h) // 2
|
||||
return (scaled_w, scaled_h, offset_x, offset_y)
|
||||
else:
|
||||
# 源更高,以高度为准,左右填充
|
||||
scaled_h = target_h
|
||||
scaled_w = int(target_h * src_ratio)
|
||||
offset_x = (target_w - scaled_w) // 2
|
||||
offset_y = 0
|
||||
return (scaled_w, scaled_h, offset_x, offset_y)
|
||||
|
||||
|
||||
# ── stream copy 判断 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def can_use_stream_copy(
|
||||
segments: list[dict],
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_fps: float,
|
||||
force_reencode: bool = False,
|
||||
) -> bool:
|
||||
"""判断是否可以使用 stream copy(无损拼接).
|
||||
|
||||
stream copy 条件:
|
||||
1. force_reencode 为 False
|
||||
2. 所有视频段的编码格式、分辨率、帧率均相同
|
||||
3. 目标参数与源参数一致(不需要转码)
|
||||
|
||||
Args:
|
||||
segments: 视频段列表,每个元素包含 codec_name/width/height/fps
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
target_fps: 目标帧率
|
||||
force_reencode: 是否强制重编码
|
||||
|
||||
Returns:
|
||||
是否可以用 stream copy
|
||||
"""
|
||||
if force_reencode:
|
||||
return False
|
||||
|
||||
if not segments:
|
||||
return False
|
||||
|
||||
# 用第一段作为基准
|
||||
first = segments[0]
|
||||
base_codec = first.get("codec_name", "")
|
||||
base_width = int(first.get("width", 0))
|
||||
base_height = int(first.get("height", 0))
|
||||
base_fps = parse_fps(first.get("r_frame_rate", "30/1"))
|
||||
|
||||
# 目标参数必须与基准一致
|
||||
if target_width != base_width or target_height != base_height:
|
||||
return False
|
||||
|
||||
if abs(target_fps - base_fps) > 0.01:
|
||||
return False
|
||||
|
||||
# 所有段必须参数一致
|
||||
for seg in segments[1:]:
|
||||
if seg.get("codec_name", "") != base_codec:
|
||||
return False
|
||||
if int(seg.get("width", 0)) != base_width:
|
||||
return False
|
||||
if int(seg.get("height", 0)) != base_height:
|
||||
return False
|
||||
seg_fps = parse_fps(seg.get("r_frame_rate", "30/1"))
|
||||
if abs(seg_fps - base_fps) > 0.01:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── 文件列表生成(demuxer 模式) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_concat_file_list(
|
||||
video_paths: list[str],
|
||||
) -> str:
|
||||
"""生成 concat demuxer 模式的文件列表内容.
|
||||
|
||||
格式:
|
||||
file '/path/to/video1.mp4'
|
||||
file '/path/to/video2.mp4'
|
||||
|
||||
Args:
|
||||
video_paths: 视频文件路径列表
|
||||
|
||||
Returns:
|
||||
文件列表文本内容
|
||||
"""
|
||||
lines = []
|
||||
for path in video_paths:
|
||||
# 转义单引号
|
||||
escaped = path.replace("'", "'\\''")
|
||||
lines.append(f"file '{escaped}'")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ── 滤镜链构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scale_pad_filter(
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
src_w: int = 0,
|
||||
src_h: int = 0,
|
||||
) -> str:
|
||||
"""构建 scale + pad 滤镜(等比缩放+黑边填充).
|
||||
|
||||
Args:
|
||||
target_w: 目标宽度
|
||||
target_h: 目标高度
|
||||
src_w: 源宽度(0 表示未知,用 iw/ih)
|
||||
src_h: 源高度(0 表示未知)
|
||||
|
||||
Returns:
|
||||
滤镜字符串
|
||||
"""
|
||||
# 使用 FFmpeg 表达式,动态计算
|
||||
return (
|
||||
f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black"
|
||||
)
|
||||
|
||||
|
||||
def build_fps_filter(fps: float) -> str:
|
||||
"""构建 fps 滤镜.
|
||||
|
||||
Args:
|
||||
fps: 目标帧率
|
||||
|
||||
Returns:
|
||||
fps 滤镜字符串
|
||||
"""
|
||||
return format_fps_filter(fps)
|
||||
|
||||
|
||||
def build_setpts_filter() -> str:
|
||||
"""构建 setpts 滤镜(重置时间戳).
|
||||
|
||||
Returns:
|
||||
setpts 滤镜字符串
|
||||
"""
|
||||
return "setpts=PTS-STARTPTS"
|
||||
|
||||
|
||||
def build_concat_filter(
|
||||
num_inputs: int,
|
||||
has_audio: bool = True,
|
||||
) -> str:
|
||||
"""构建 concat 滤镜.
|
||||
|
||||
Args:
|
||||
num_inputs: 输入数量
|
||||
has_audio: 是否包含音频轨
|
||||
|
||||
Returns:
|
||||
concat 滤镜字符串(包含输入标签)
|
||||
"""
|
||||
if num_inputs <= 0:
|
||||
return ""
|
||||
|
||||
n = num_inputs
|
||||
v = 1 # 视频轨数
|
||||
a = 1 if has_audio else 0 # 音频轨数
|
||||
|
||||
# 构建输入标签
|
||||
input_labels = "".join(f"[{i}:v][{i}:a]" if has_audio else f"[{i}:v]" for i in range(n))
|
||||
|
||||
output_label = "[concat_v]" + ("[concat_a]" if has_audio else "")
|
||||
|
||||
return f"{input_labels}concat=n={n}:v={v}:a={a}{output_label}"
|
||||
|
||||
|
||||
def build_single_segment_filter_chain(
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_fps: float,
|
||||
segment_index: int,
|
||||
has_audio: bool = True,
|
||||
) -> str:
|
||||
"""构建单段视频的预处理滤镜链.
|
||||
|
||||
每段视频需要:缩放填充 → 帧率统一 → 重置时间戳
|
||||
|
||||
Args:
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
target_fps: 目标帧率
|
||||
segment_index: 段索引(用于生成标签)
|
||||
has_audio: 是否包含音频
|
||||
|
||||
Returns:
|
||||
滤镜字符串
|
||||
"""
|
||||
scale_pad = build_scale_pad_filter(target_width, target_height)
|
||||
fps = build_fps_filter(target_fps)
|
||||
setpts = build_setpts_filter()
|
||||
|
||||
input_v = f"[{segment_index}:v]"
|
||||
output_v = f"[v{segment_index}]"
|
||||
|
||||
video_chain = f"{input_v}{scale_pad},{fps},{setpts}{output_v}"
|
||||
|
||||
if has_audio:
|
||||
input_a = f"[{segment_index}:a]"
|
||||
output_a = f"[a{segment_index}]"
|
||||
# 音频也需要重置时间戳
|
||||
audio_chain = f"{input_a}asetpts=PTS-STARTPTS{output_a}"
|
||||
return f"{video_chain};{audio_chain}"
|
||||
|
||||
return video_chain
|
||||
|
||||
|
||||
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_concat_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证拼接配置.
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
segments = config.get("segments", [])
|
||||
if not segments:
|
||||
errors.append("至少需要一个视频段")
|
||||
return (False, errors)
|
||||
|
||||
if len(segments) < 1:
|
||||
errors.append("视频段数量不能少于 1")
|
||||
|
||||
# 检查每个段
|
||||
for i, seg in enumerate(segments):
|
||||
video_path = seg.get("video_path", "")
|
||||
if not video_path:
|
||||
errors.append(f"第 {i+1} 段缺少 video_path")
|
||||
|
||||
# 输出参数
|
||||
output_width = config.get("output_width", 0)
|
||||
output_height = config.get("output_height", 0)
|
||||
if output_width < 0:
|
||||
errors.append("output_width 不能为负数")
|
||||
if output_height < 0:
|
||||
errors.append("output_height 不能为负数")
|
||||
|
||||
output_fps = config.get("output_fps", 0)
|
||||
if output_fps < 0:
|
||||
errors.append("output_fps 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 路径验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_video_path(video_path: str, work_dir: str | Path) -> tuple[bool, str]:
|
||||
"""验证视频路径是否安全.
|
||||
|
||||
检查:
|
||||
1. 路径不为空
|
||||
2. 路径不包含 .. 回溯
|
||||
3. 路径在 work_dir 内(安全边界)
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息)
|
||||
"""
|
||||
if not video_path:
|
||||
return (False, "视频路径不能为空")
|
||||
|
||||
path_str = str(video_path)
|
||||
work_str = str(work_dir)
|
||||
|
||||
# 检查路径遍历
|
||||
if ".." in Path(path_str).parts:
|
||||
return (False, "视频路径不能包含 .. 回溯")
|
||||
|
||||
# 绝对路径才做边界检查;相对路径默认相对于 work_dir
|
||||
if not Path(path_str).is_absolute():
|
||||
return (True, "")
|
||||
|
||||
# 绝对路径检查是否在工作目录内
|
||||
try:
|
||||
video_abs = Path(path_str).resolve()
|
||||
work_abs = Path(work_str).resolve()
|
||||
if work_abs.is_absolute() and not str(video_abs).startswith(str(work_abs)):
|
||||
return (False, "视频路径必须在工作目录内")
|
||||
except (OSError, ValueError):
|
||||
pass # 解析失败时跳过边界检查
|
||||
|
||||
return (True, "")
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_total_duration(segments: list[dict]) -> float:
|
||||
"""估算总时长.
|
||||
|
||||
Args:
|
||||
segments: 视频段列表,每个元素包含 duration 字段
|
||||
|
||||
Returns:
|
||||
总时长(秒)
|
||||
"""
|
||||
total = 0.0
|
||||
for seg in segments:
|
||||
dur = seg.get("duration", 0)
|
||||
try:
|
||||
total += float(dur)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def count_valid_segments(segments: list[dict]) -> int:
|
||||
"""统计有效视频段数量(有 video_path 的).
|
||||
|
||||
Args:
|
||||
segments: 视频段列表
|
||||
|
||||
Returns:
|
||||
有效段数量
|
||||
"""
|
||||
count = 0
|
||||
for seg in segments:
|
||||
if seg.get("video_path"):
|
||||
count += 1
|
||||
return count
|
||||
Executable
+534
@@ -0,0 +1,534 @@
|
||||
"""视频拼接引擎纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine_pure import (
|
||||
build_concat_filter,
|
||||
build_fps_filter,
|
||||
build_scale_pad_filter,
|
||||
build_single_segment_filter_chain,
|
||||
calculate_scaled_size,
|
||||
can_use_stream_copy,
|
||||
count_valid_segments,
|
||||
estimate_total_duration,
|
||||
format_fps_filter,
|
||||
generate_concat_file_list,
|
||||
parse_fps,
|
||||
resolve_output_params,
|
||||
validate_concat_config,
|
||||
validate_video_path,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 帧率解析测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseFps:
|
||||
"""parse_fps 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert parse_fps(30) == 30.0
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
assert parse_fps(29.97) == pytest.approx(29.97)
|
||||
|
||||
def test_string_integer(self):
|
||||
"""字符串整数."""
|
||||
assert parse_fps("30") == 30.0
|
||||
|
||||
def test_string_fraction(self):
|
||||
"""分数字符串(30/1)."""
|
||||
assert parse_fps("30/1") == 30.0
|
||||
|
||||
def test_fraction_24000_1001(self):
|
||||
"""23.976 帧率."""
|
||||
result = parse_fps("24000/1001")
|
||||
assert result == pytest.approx(23.976, rel=0.01)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入返回默认值."""
|
||||
assert parse_fps(None) == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回默认值."""
|
||||
assert parse_fps("") == 30.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert parse_fps("abc") == 30.0
|
||||
|
||||
def test_zero_denominator(self):
|
||||
"""分母为 0."""
|
||||
assert parse_fps("30/0") == 30.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
assert parse_fps(-30) == -30.0
|
||||
|
||||
|
||||
class TestFormatFpsFilter:
|
||||
"""format_fps_filter 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert format_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = format_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
assert "29.97" in result
|
||||
|
||||
def test_near_integer(self):
|
||||
"""接近整数."""
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 输出参数计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputParams:
|
||||
"""resolve_output_params 测试."""
|
||||
|
||||
def test_all_specified(self):
|
||||
"""全部显式指定."""
|
||||
w, h, fps = resolve_output_params(1920, 1080, 60.0)
|
||||
assert w == 1920
|
||||
assert h == 1080
|
||||
assert fps == 60.0
|
||||
|
||||
def test_no_specified_use_defaults(self):
|
||||
"""全部未指定,用默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080
|
||||
assert h == 1920
|
||||
assert fps == 30.0
|
||||
|
||||
def test_use_first_video_info(self):
|
||||
"""用第一段视频信息."""
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert w == 1280
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_partial_specified(self):
|
||||
"""部分指定,未指定的用探测值."""
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(1920, 0, 0, info)
|
||||
assert w == 1920 # 指定的
|
||||
assert h == 720 # 探测的
|
||||
assert fps == 24.0
|
||||
|
||||
def test_zero_size_clamped(self):
|
||||
"""零尺寸被钳制."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_custom_defaults(self):
|
||||
"""自定义默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
|
||||
assert w == 640
|
||||
assert h == 480
|
||||
assert fps == 25.0
|
||||
|
||||
|
||||
class TestCalculateScaledSize:
|
||||
"""calculate_scaled_size 测试."""
|
||||
|
||||
def test_same_ratio(self):
|
||||
"""比例相同."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_wider_source(self):
|
||||
"""源更宽,上下填黑边."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
|
||||
assert sw == 1080 # 以宽度为准
|
||||
assert sh < 1920 # 高度按比例
|
||||
assert ox == 0
|
||||
assert oy > 0 # 垂直居中
|
||||
|
||||
def test_taller_source(self):
|
||||
"""源更高,左右填黑边."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
|
||||
assert sh == 1080 # 以高度为准
|
||||
assert sw < 1920 # 宽度按比例
|
||||
assert ox > 0 # 水平居中
|
||||
assert oy == 0
|
||||
|
||||
def test_zero_source(self):
|
||||
"""零尺寸源."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
|
||||
assert sw == 100
|
||||
assert sh == 100
|
||||
|
||||
def test_scale_down(self):
|
||||
"""缩小."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
|
||||
assert sw == 640
|
||||
assert sh == 360
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_scale_up(self):
|
||||
"""放大."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stream copy 判断测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCanUseStreamCopy:
|
||||
"""can_use_stream_copy 测试."""
|
||||
|
||||
def test_identical_segments(self):
|
||||
"""所有段参数相同,可以 stream copy."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重编码."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_different_codec(self):
|
||||
"""编码不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_resolution(self):
|
||||
"""分辨率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_fps(self):
|
||||
"""帧率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_target_differs(self):
|
||||
"""目标参数与源不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空列表."""
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段."""
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 文件列表生成测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateConcatFileList:
|
||||
"""generate_concat_file_list 测试."""
|
||||
|
||||
def test_single_file(self):
|
||||
"""单个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4"])
|
||||
assert "file '/a.mp4'" in result
|
||||
assert result.endswith("\n")
|
||||
|
||||
def test_multiple_files(self):
|
||||
"""多个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "file '/a.mp4'"
|
||||
assert lines[1] == "file '/b.mp4'"
|
||||
assert lines[2] == "file '/c.mp4'"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
result = generate_concat_file_list([])
|
||||
assert result == "\n"
|
||||
|
||||
def test_path_with_single_quote(self):
|
||||
"""路径包含单引号(转义)."""
|
||||
result = generate_concat_file_list(["/path/to/file's.mp4"])
|
||||
# 单引号应该被转义
|
||||
assert "'\\''" in result or file
|
||||
assert "file '" in result
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""路径包含空格."""
|
||||
result = generate_concat_file_list(["/path/to/my video.mp4"])
|
||||
assert "my video" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScalePadFilter:
|
||||
"""scale+pad 滤镜测试."""
|
||||
|
||||
def test_contains_scale(self):
|
||||
"""包含 scale."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=" in result
|
||||
|
||||
def test_contains_pad(self):
|
||||
"""包含 pad."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "pad=" in result
|
||||
assert "1920:1080" in result
|
||||
|
||||
def test_force_original_aspect_ratio(self):
|
||||
"""保持宽高比."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "force_original_aspect_ratio=decrease" in result
|
||||
|
||||
def test_black_padding(self):
|
||||
"""黑边填充."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert ":black" in result
|
||||
|
||||
|
||||
class TestBuildFpsFilter:
|
||||
"""fps 滤镜测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert build_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = build_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜测试."""
|
||||
|
||||
def test_two_inputs_with_audio(self):
|
||||
"""两路输入,有音频."""
|
||||
result = build_concat_filter(2, has_audio=True)
|
||||
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
|
||||
assert "[concat_v][concat_a]" in result
|
||||
|
||||
def test_three_inputs_video_only(self):
|
||||
"""三路输入,无音频."""
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
|
||||
assert "[concat_v]" in result
|
||||
|
||||
def test_single_input(self):
|
||||
"""单路输入."""
|
||||
result = build_concat_filter(1, has_audio=True)
|
||||
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_concat_filter(0) == ""
|
||||
|
||||
|
||||
class TestBuildSingleSegmentFilterChain:
|
||||
"""单段滤镜链测试."""
|
||||
|
||||
def test_with_audio(self):
|
||||
"""有音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
|
||||
assert "scale=" in result
|
||||
assert "fps=" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
assert "[v0]" in result
|
||||
assert "[a0]" in result
|
||||
|
||||
def test_video_only(self):
|
||||
"""无音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
|
||||
assert "scale=" in result
|
||||
assert "setpts=" in result
|
||||
assert "asetpts" not in result
|
||||
assert "[v1]" in result
|
||||
|
||||
def test_segment_index_in_labels(self):
|
||||
"""段索引在标签中."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
|
||||
assert "[5:v]" in result
|
||||
assert "[v5]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateConcatConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30,
|
||||
}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空段列表."""
|
||||
ok, errors = validate_concat_config({"segments": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e or "视频段" in e for e in errors)
|
||||
|
||||
def test_missing_video_path(self):
|
||||
"""缺少 video_path."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("video_path" in e for e in errors)
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_width" in e for e in errors)
|
||||
|
||||
def test_negative_height(self):
|
||||
"""负高度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_height" in e for e in errors)
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_fps" in e for e in errors)
|
||||
|
||||
def test_zero_output_params_ok(self):
|
||||
"""零输出参数合法(表示自动探测)."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 路径验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateVideoPath:
|
||||
"""视频路径验证测试."""
|
||||
|
||||
def test_empty_path(self):
|
||||
"""空路径."""
|
||||
ok, msg = validate_video_path("", "/work")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_path_traversal(self):
|
||||
"""路径遍历."""
|
||||
ok, msg = validate_video_path("../etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "回溯" in msg or ".." in msg
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""相对路径(不检查边界)."""
|
||||
ok, msg = validate_video_path("video.mp4", "/work")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_absolute_path(self):
|
||||
"""绝对路径在工作目录内."""
|
||||
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert ok is True
|
||||
|
||||
def test_path_outside_work_dir(self):
|
||||
"""路径在工作目录外."""
|
||||
ok, msg = validate_video_path("/etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "工作目录" in msg
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateTotalDuration:
|
||||
"""总时长估算测试."""
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多段视频."""
|
||||
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(35.5)
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_invalid_duration_skipped(self):
|
||||
"""无效时长跳过."""
|
||||
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(30.0)
|
||||
|
||||
def test_missing_duration(self):
|
||||
"""缺 duration 字段."""
|
||||
segs = [{}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(10.0)
|
||||
|
||||
|
||||
class TestCountValidSegments:
|
||||
"""有效段统计测试."""
|
||||
|
||||
def test_all_valid(self):
|
||||
"""全部有效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_some_invalid(self):
|
||||
"""部分无效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
|
||||
assert count_valid_segments(segs) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_valid_segments([]) == 0
|
||||
Reference in New Issue
Block a user