"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片. 基于 FFmpeg 实现两种拼接模式: 1. **concat demuxer(stream copy)**:最快,所有视频编码参数必须一致 2. **concat filter(重新编码)**:更灵活,支持不同分辨率/编码/帧率的视频 使用场景: - 多段素材按顺序合并成一个视频 - 视频分割后重新拼接 - 片头 + 正片 + 片尾拼接 降级策略: - 优先尝试 stream copy(速度快、无质量损失) - 参数不一致时自动降级到 concat filter - 某段视频失败时跳过,不阻断整体拼接 """ from __future__ import annotations import logging 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 from packages.domain.video_concat import ( # noqa: F401 向后兼容导出 ALLOWED_VIDEO_EXTENSIONS, CONCAT_DEMUXER_REQUIRED_PARAMS, MAX_CONCAT_SEGMENTS, ConcatConfig, ConcatSegment, ) logger = logging.getLogger(__name__) # ── 路径安全校验 ──────────────────────────────────────────────────────────── 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 demuxer(stream 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 demuxer(stream 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 # ── 模式1:concat demuxer(stream 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 # ── 模式2:concat 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