Files
xiaoxia-saas/apps/worker/video_processing/concat_engine.py
T
xiaoxia 09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
feat(ci): P1-1 Phase2 后端启用F401+F841并修复存量 (#470)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-17 14:06:50 +08:00

696 lines
25 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
基于 FFmpeg 实现两种拼接模式:
1. **concat demuxerstream copy**:最快,所有视频编码参数必须一致
2. **concat filter(重新编码)**:更灵活,支持不同分辨率/编码/帧率的视频
使用场景:
- 多段素材按顺序合并成一个视频
- 视频分割后重新拼接
- 片头 + 正片 + 片尾拼接
降级策略:
- 优先尝试 stream copy(速度快、无质量损失)
- 参数不一致时自动降级到 concat filter
- 某段视频失败时跳过,不阻断整体拼接
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
# concat demuxer 要求一致的参数列表
CONCAT_DEMUXER_REQUIRED_PARAMS = [
"codec_name", # 视频编码
"width", # 宽度
"height", # 高度
"r_frame_rate", # 帧率
"pix_fmt", # 像素格式
"sample_rate", # 音频采样率
"channels", # 音频声道数
"audio_codec", # 音频编码
]
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
@dataclass
class ConcatSegment:
"""单个拼接片段."""
video_path: str # 视频文件路径
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
has_audio: bool = True # 是否包含音频
@classmethod
def from_dict(cls, seg: dict) -> "ConcatSegment":
"""从字典创建拼接片段,带安全类型转换."""
try:
start_time = max(0.0, float(seg.get("start_time", 0.0)))
except (TypeError, ValueError):
start_time = 0.0
try:
duration = max(0.0, float(seg.get("duration", 0.0)))
except (TypeError, ValueError):
duration = 0.0
return cls(
video_path=str(seg.get("video_path", "")),
start_time=start_time,
duration=duration,
has_audio=bool(seg.get("has_audio", True)),
)
@dataclass
class ConcatConfig:
"""视频拼接配置."""
segments: list[ConcatSegment] = field(default_factory=list)
output_width: int = 0 # 输出宽度(0=自动取第一段)
output_height: int = 0 # 输出高度(0=自动取第一段)
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
force_reencode: bool = False # 强制重新编码(不用 stream copy
transition: str = "none" # 转场效果(none/crossfade- 预留
transition_duration: float = 0.3 # 转场时长
@classmethod
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
"""从配置字典创建 ConcatConfig."""
if not config or not isinstance(config, dict):
return cls()
segments_raw = config.get("segments", [])
segments: list[ConcatSegment] = []
if isinstance(segments_raw, list):
for s in segments_raw:
if isinstance(s, dict) and s.get("video_path"):
try:
seg = ConcatSegment.from_dict(s)
if seg.video_path:
segments.append(seg)
except Exception:
logger.warning("[concat] skip invalid segment: %s", s)
continue
try:
output_width = max(0, int(config.get("output_width", 0)))
except (TypeError, ValueError):
output_width = 0
try:
output_height = max(0, int(config.get("output_height", 0)))
except (TypeError, ValueError):
output_height = 0
try:
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
except (TypeError, ValueError):
output_fps = 0.0
return cls(
segments=segments,
output_width=output_width,
output_height=output_height,
output_fps=output_fps,
force_reencode=bool(config.get("force_reencode", False)),
transition=str(config.get("transition", "none")),
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
)
@property
def has_effect(self) -> bool:
"""是否有有效片段需要拼接."""
return len([s for s in self.segments if s.video_path]) >= 2
@property
def total_segments(self) -> int:
"""有效片段数量."""
return len([s for s in self.segments if s.video_path])
# ── 路径安全校验 ────────────────────────────────────────────────────────────
def _validate_video_path(video_path: str, work_dir: Path) -> None:
"""校验视频文件路径安全性.
规则:
- local:// schema → 必须在 work_dir 内
- 相对路径 → 必须在 work_dir 内
- 绝对路径 → 必须在允许目录白名单内
- 扩展名必须是视频格式
Raises:
PathSecurityError: 路径不安全
"""
if not video_path or not isinstance(video_path, str):
raise PathSecurityError("视频路径不能为空")
# 本地路径(local:// 或相对路径 / 绝对路径)
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
resolved_path = safe_resolve_path(
video_path,
work_dir,
allow_outside=is_abs,
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
)
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
if is_abs:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError as _e:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
path_part = video_path.split("?")[0].split("#")[0]
ext = Path(path_part).suffix.lower()
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
class ConcatEngine:
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
def __init__(self, work_dir: Path):
self.work_dir = work_dir
self.work_dir.mkdir(parents=True, exist_ok=True)
# ── 主入口 ────────────────────────────────────────────────────────
def concat_videos(
self,
config: ConcatConfig,
output_path: Path,
) -> Path:
"""拼接多段视频.
自动选择最优拼接策略:
1. 所有片段参数一致 → concat demuxerstream copy,最快)
2. 参数不一致或有裁剪 → concat filter(重新编码)
Args:
config: 拼接配置
output_path: 输出文件路径
Returns:
输出文件路径
"""
valid_segments = [s for s in config.segments if s.video_path]
if not valid_segments:
raise ValueError("No valid video segments to concat")
# ── 安全校验:段数上限 ──
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
# ── 安全校验:所有视频路径白名单校验 ──
safe_segments = []
for seg in valid_segments:
try:
_validate_video_path(seg.video_path, self.work_dir)
safe_segments.append(seg)
except PathSecurityError as e:
logger.warning("[concat] skip segment: path security check failed: %s", e)
if len(safe_segments) != len(valid_segments):
valid_segments = safe_segments
config.segments = safe_segments
logger.info("[concat] %d segments passed security check", len(safe_segments))
if not valid_segments:
raise ValueError("No valid video segments after security check")
if len(valid_segments) == 1:
# 只有一段,直接复制
import shutil
logger.info("[concat] single segment, copy directly")
shutil.copy2(valid_segments[0].video_path, output_path)
return output_path
# 判断能否用 stream copy
can_stream_copy = self._can_use_stream_copy(config)
if can_stream_copy and not config.force_reencode:
logger.info("[concat] using concat demuxer (stream copy)")
try:
return self._concat_demuxer(config, output_path)
except Exception as e:
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
# 降级到 concat filter
logger.info("[concat] using concat filter (re-encode)")
return self._concat_filter(config, output_path)
# ── 模式判断 ──────────────────────────────────────────────────────
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
"""判断是否可以使用 concat demuxerstream copy.
条件:
1. 所有视频编码参数一致(分辨率、帧率、编码、像素格式)
2. 所有音频参数一致(采样率、声道、编码)
3. 没有设置 start_time 裁剪(或可以通过 concat demuxer 的 inpoint/outpoint 实现)
4. 没有强制重新编码
"""
if config.force_reencode:
return False
# 如果有转场效果,必须重新编码
if config.transition != "none":
return False
# 探测所有视频的参数
video_infos = []
for seg in config.segments:
if not seg.video_path:
continue
try:
info = probe_video_info(seg.video_path)
video_infos.append(info)
except Exception:
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
return False
if len(video_infos) < 2:
return False
# 检查参数一致性
base_info = video_infos[0]
for info in video_infos[1:]:
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
base_val = base_info.get(param)
curr_val = info.get(param)
if base_val != curr_val:
logger.debug(
"[concat] param mismatch: %s (%s vs %s)",
param,
base_val,
curr_val,
)
return False
# 检查是否有裁剪需求
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
# 但为了简单和稳定性,有裁剪时也用 filter 模式
# inpoint/outpoint 不是所有格式都支持得好)
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
if has_trimming:
return False
return True
# ── 模式1concat demuxerstream copy) ──────────────────────────
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
"""使用 concat demuxer 拼接(stream copy.
优点:速度极快,无质量损失
缺点:要求所有视频参数完全一致
"""
# 生成 concat 文件列表
list_file = self.work_dir / "concat_list.txt"
lines = []
for seg in config.segments:
if not seg.video_path:
continue
# 路径转义:单引号替换为 '\''
safe_path = str(seg.video_path).replace("'", "'\\''")
lines.append(f"file '{safe_path}'")
list_file.write_text("\n".join(lines), encoding="utf-8")
command = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-c",
"copy",
"-copyts",
str(output_path),
]
logger.info("[concat] demuxer: %d segments", config.total_segments)
run_ffmpeg(command)
return output_path
# ── 模式2concat filter(重新编码) ──────────────────────────────
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
"""使用 concat filter 拼接(重新编码).
优点:支持不同参数的视频,支持裁剪
缺点:需要重新编码,较慢
"""
valid_segments = [s for s in config.segments if s.video_path]
num_segments = len(valid_segments)
# 构建输入参数
input_args: list[str] = []
for seg in valid_segments:
input_args.extend(["-i", seg.video_path])
# 确定输出参数
output_width, output_height, output_fps = self._get_output_params(config)
# 构建 filter_complex
filter_parts: list[str] = []
concat_inputs = ""
for i, seg in enumerate(valid_segments):
vid_label = f"v{i}"
aud_label = f"a{i}"
seg_filters: list[str] = []
# 1. 裁剪(start_time + duration
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
else:
seg_filters.append(f"trim=start={start:.3f}")
seg_filters.append("setpts=PTS-STARTPTS")
# 音频同步裁剪
if seg.has_audio:
if seg.duration > 0:
filter_parts.append(
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
)
else:
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
else:
# 无音频时生成静音轨
filter_parts.append(
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
)
else:
# 无裁剪,直接用原始标签
if not seg.has_audio:
# 无音频时需要生成静音
try:
dur = probe_duration(seg.video_path)
except Exception:
dur = 10.0
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
# 2. 缩放/帧率统一
vf_parts = []
if not seg_filters:
vf_parts.append(f"[{i}:v]")
else:
vf_parts.append("")
# 分辨率统一
if output_width and output_height:
vf_parts.append(
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
)
# 帧率统一
if output_fps > 0:
vf_parts.append(f"fps={output_fps}")
# 像素格式统一
vf_parts.append("format=yuv420p")
if len(vf_parts) > 1 or (seg_filters and vf_parts):
if seg_filters:
# 先裁剪后缩放
crop_str = "".join(seg_filters)
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
if scale_str:
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
else:
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
else:
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
else:
if seg_filters:
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
else:
# 什么都不需要,直接用输入
pass
# 拼接 concat 的输入标签
if seg_filters or (output_width and output_height) or output_fps > 0:
concat_inputs += f"[{vid_label}]"
else:
concat_inputs += f"[{i}:v]"
# 音频标签
if seg.start_time > 0 or seg.duration > 0:
# 已经生成了 aud_label
pass
elif not seg.has_audio:
# 已经生成了静音 aud_label
pass
else:
# 使用原始音频
pass
# 简化处理:用更直接的方式构建 filter
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
filter_parts.clear()
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
for i, seg in enumerate(valid_segments):
v_label = f"v{i}_in"
a_label = f"a{i}_in"
# 视频处理链
v_steps: list[str] = [f"[{i}:v]"]
# 裁剪
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
else:
v_steps.append(f"trim=start={start:.3f},")
v_steps.append("setpts=PTS-STARTPTS,")
# 缩放
if output_width and output_height:
v_steps.append(
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
)
# 帧率
if output_fps > 0:
v_steps.append(f"fps={output_fps},")
# 像素格式
v_steps.append("format=yuv420p")
v_filter = "".join(v_steps) + f"[{v_label}]"
filter_parts.append(v_filter)
# 音频处理链
a_steps: list[str] = []
if seg.has_audio:
a_steps.append(f"[{i}:a]")
if seg.start_time > 0 or seg.duration > 0:
start = seg.start_time
if seg.duration > 0:
end = start + seg.duration
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
else:
a_steps.append(f"atrim=start={start:.3f},")
a_steps.append("asetpts=PTS-STARTPTS,")
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
else:
# 生成静音音频
try:
dur = probe_duration(seg.video_path)
except Exception:
dur = 10.0
# 减去裁剪
if seg.start_time > 0:
dur = max(0.1, dur - seg.start_time)
if seg.duration > 0 and seg.duration < dur:
dur = seg.duration
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
a_filter = "".join(a_steps) + f"[{a_label}]"
filter_parts.append(a_filter)
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
concat_inputs += f"[{v_label}][{a_label}]"
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[vout]",
"-map",
"[aout]",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(output_path),
]
logger.info(
"[concat] filter: %d segments, %dx%d, %.2f fps",
num_segments,
output_width,
output_height,
output_fps,
)
run_ffmpeg(command)
return output_path
# ── 辅助方法 ──────────────────────────────────────────────────────
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
"""获取输出参数(宽、高、帧率).
优先级:
1. config 中显式指定的
2. 第一段视频的参数
"""
valid_segments = [s for s in config.segments if s.video_path]
width = config.output_width
height = config.output_height
fps = config.output_fps
# 如果没有显式指定,用第一段的参数
if (width == 0 or height == 0 or fps == 0) and valid_segments:
try:
info = probe_video_info(valid_segments[0].video_path)
if width == 0:
width = int(info.get("width", 1080))
if height == 0:
height = int(info.get("height", 1920))
if fps == 0:
fps_str = info.get("r_frame_rate", "30/1")
if "/" in str(fps_str):
num, den = str(fps_str).split("/")
try:
fps = float(num) / float(den)
except (ValueError, ZeroDivisionError):
fps = 30.0
else:
fps = float(fps_str) if fps_str else 30.0
except Exception:
# 探测失败,用默认值
if width == 0:
width = 1080
if height == 0:
height = 1920
if fps == 0:
fps = 30.0
return width, height, fps
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def concat_video_files(
video_paths: list[str],
output_path: Path,
*,
work_dir: Path | None = None,
force_reencode: bool = False,
) -> Path:
"""简单拼接多个视频文件.
Args:
video_paths: 视频文件路径列表
output_path: 输出路径
work_dir: 工作目录(默认输出文件所在目录)
force_reencode: 是否强制重新编码
Returns:
输出文件路径
"""
if work_dir is None:
work_dir = output_path.parent
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
engine = ConcatEngine(work_dir)
return engine.concat_videos(config, output_path)
def concat_videos_from_config(
config_dict: dict | None,
output_path: Path,
*,
work_dir: Path,
) -> Path | None:
"""从配置字典执行视频拼接.
降级策略:配置无效或拼接失败时返回 None.
"""
config = ConcatConfig.from_config_dict(config_dict)
if not config.has_effect:
return None
try:
engine = ConcatEngine(work_dir)
return engine.concat_videos(config, output_path)
except Exception as e:
logger.error("[concat] concat failed: %s", e)
return None