494 lines
14 KiB
Python
Executable File
494 lines
14 KiB
Python
Executable File
"""视频拼接引擎纯逻辑模块.
|
|
|
|
所有函数均为纯函数,不调用 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
|