"""裁剪引擎 — 基于 FFmpeg trim/atrim 的精确帧级裁剪. 支持: - 入点出点裁剪(start_time / end_time / duration 三选二) - 边界自动钳制(超出素材时长自动修正,不阻断渲染) - 多段裁剪(一个素材裁剪出多段) - 音画同步(视频 + 音频同步裁剪) """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any logger = logging.getLogger(__name__) # 最小裁剪时长(秒),低于此值视为无效 MIN_TRIM_DURATION = 0.1 @dataclass class TrimConfig: """裁剪配置. 三选二规则:start_time / end_time / duration 中必须至少给出两个, 第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。 边界保护: - start_time < 0 → 钳制到 0 - end_time > 素材时长 → 钳制到素材时长 - 计算出的 duration < 最小阈值 → 标记为无效 """ start_time: float = 0.0 # 入点(素材内时间,秒) end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定 duration: float = 0.0 # 裁剪时长(秒),0 表示未指定 @classmethod def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None: """从字典构造,无有效裁剪参数时返回 None(不裁剪).""" if not data: return None start = float(data.get("start_time", 0) or 0) end = float(data.get("end_time", 0) or 0) dur = float(data.get("duration", 0) or 0) # 三个参数都没有 → 不裁剪 if start <= 0 and end <= 0 and dur <= 0: return None # 至少有两个参数(或一个合理的 start/duration) # 兼容:只传了 start_time → 从 start 开始取到末尾 # 兼容:只传了 duration → 从 0 开始取 duration if start > 0 and end <= 0 and dur <= 0: # 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效 pass elif dur > 0 and start <= 0 and end <= 0: # 只有 duration → 从开头取 duration,算有效 pass elif start <= 0 and end <= 0 and dur <= 0: return None return cls(start_time=start, end_time=end, duration=dur) def validate_and_resolve(self, asset_duration: float) -> TrimConfig: """根据素材实际时长,解析并钳制裁剪参数. 返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。 如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。 """ start = self.start_time end = self.end_time dur = self.duration # 边界:start 不能为负 if start < 0: start = 0.0 # 边界:asset_duration 为 0 时保守处理(不裁剪,取全部) if asset_duration <= 0: return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0) # 三选二推导 # 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的 # 情况1:start + end 都有显式值 if start > 0 and end > 0: if end <= start: # 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效) return TrimConfig(start_time=start, end_time=start, duration=0.0) dur = end - start # 情况2:end + duration 都有显式值 elif end > 0 and dur > 0: start = end - dur if start < 0: start = 0.0 dur = end # 重新计算 # 情况3:start + duration 都有值(start 可以是 0) elif dur > 0: end = start + dur # 情况4:只有 start → 取到素材末尾 elif start > 0 and end <= 0 and dur <= 0: end = asset_duration dur = end - start # 情况5:只有 end → 从开头取到 end elif end > 0 and start <= 0 and dur <= 0: start = 0.0 dur = end else: # 都没有 → 不裁剪 return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0) # 边界钳制:end 不能超过素材时长 if end > asset_duration: end = asset_duration dur = end - start # 边界钳制:start 不能超过素材时长 if start >= asset_duration: start = max(0.0, asset_duration - MIN_TRIM_DURATION) dur = asset_duration - start end = asset_duration # 保证 duration 不为负 if dur < 0: dur = 0.0 return TrimConfig(start_time=start, end_time=end, duration=dur) @property def is_valid(self) -> bool: """裁剪是否有效(时长大于最小阈值).""" return self.duration >= MIN_TRIM_DURATION @property def is_noop(self) -> bool: """是否等价于不裁剪(从0开始取全部).""" return self.start_time <= 0 and self.duration <= 0 @property def trim_from_start(self) -> bool: """是否从开头裁剪(start_time == 0).""" return self.start_time <= 0 @dataclass class TrimSegment: """多段裁剪中的一段.""" segment_id: str # 段 ID(用于生成唯一标签) trim: TrimConfig # 裁剪配置 order: int = 0 # 排序 @classmethod def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment: """从字典构造.""" return cls( segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"), trim=TrimConfig( start_time=float(data.get("start_time", 0) or 0), end_time=float(data.get("end_time", 0) or 0), duration=float(data.get("duration", 0) or 0), ), order=int(data.get("order", default_order)), ) class TrimEngine: """裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜.""" @staticmethod def build_video_trim_filter( input_label: str, trim: TrimConfig, output_label: str, ) -> str: """构建视频裁剪滤镜链. Args: input_label: 输入视频标签,如 "[0:v]" trim: 裁剪配置(已解析钳制) output_label: 输出视频标签,如 "[v0_trimmed]" Returns: FFmpeg filter 字符串,如 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]" """ if trim.is_noop: # 不裁剪,直接直通(仅重置时间戳) return f"{input_label}setpts=PTS-STARTPTS{output_label}" parts: list[str] = [] # trim 滤镜参数 trim_args: list[str] = [] if trim.start_time > 0: trim_args.append(f"start={trim.start_time:.3f}") if trim.duration > 0: trim_args.append(f"duration={trim.duration:.3f}") elif trim.end_time > 0: # end 用 duration 表示(start 到 end 的时长) # 但 validate_and_resolve 后应该已经有 duration 了 pass parts.append(f"trim={':'.join(trim_args)}") parts.append("setpts=PTS-STARTPTS") filter_str = f"{input_label}{','.join(parts)}{output_label}" return filter_str @staticmethod def build_audio_trim_filter( input_label: str, trim: TrimConfig, output_label: str, ) -> str: """构建音频裁剪滤镜链. Args: input_label: 输入音频标签,如 "[0:a]" trim: 裁剪配置(已解析钳制) output_label: 输出音频标签,如 "[a0_trimmed]" Returns: FFmpeg filter 字符串,如 "[0:a]atrim=start=10:duration=5,asetpts=PTS-STARTPTS[a0_trimmed]" """ if trim.is_noop: return f"{input_label}asetpts=PTS-STARTPTS{output_label}" parts: list[str] = [] trim_args: list[str] = [] if trim.start_time > 0: trim_args.append(f"start={trim.start_time:.3f}") if trim.duration > 0: trim_args.append(f"duration={trim.duration:.3f}") parts.append(f"atrim={':'.join(trim_args)}") parts.append("asetpts=PTS-STARTPTS") filter_str = f"{input_label}{','.join(parts)}{output_label}" return filter_str @staticmethod def resolve_segments( segments: list[TrimSegment], asset_duration: float, ) -> list[TrimSegment]: """解析并钳制多段裁剪配置,过滤无效段. Args: segments: 原始段列表 asset_duration: 素材实际时长 Returns: 解析后的有效段列表,按 order 排序 """ resolved: list[TrimSegment] = [] for i, seg in enumerate(segments): resolved_trim = seg.trim.validate_and_resolve(asset_duration) if not resolved_trim.is_valid: logger.warning("裁剪段无效,跳过: segment_id=%s duration=%.3f", seg.segment_id, resolved_trim.duration) continue resolved.append( TrimSegment( segment_id=seg.segment_id, trim=resolved_trim, order=seg.order if seg.order >= 0 else i, ) ) resolved.sort(key=lambda s: s.order) return resolved @staticmethod def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]: """从 clip config 中解析多段裁剪配置. config 中支持: - trim_segments: [ {segment_id, start_time, end_time, duration, order}, ... ] - trim_start / trim_end / trim_duration: 单段裁剪(兼容旧格式) """ if not config: return [] # 优先解析多段 raw_segments = config.get("trim_segments", []) if raw_segments and isinstance(raw_segments, list): segments = [] for i, raw in enumerate(raw_segments): if isinstance(raw, dict): segments.append(TrimSegment.from_dict(raw, default_order=i)) return segments # 单段裁剪兼容:从 trim_start/trim_end/trim_duration 构造 has_single = any(k in config for k in ("trim_start", "trim_end", "trim_duration")) if has_single: seg = TrimSegment( segment_id="main", trim=TrimConfig( start_time=float(config.get("trim_start", 0) or 0), end_time=float(config.get("trim_end", 0) or 0), duration=float(config.get("trim_duration", 0) or 0), ), order=0, ) return [seg] return [] # ── 工具函数 ────────────────────────────────────────────────────────────────── def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None: """从 clip config 中提取单段裁剪配置. 兼容以下字段名: - trim_start / trim_end / trim_duration - start_time / end_time / duration(在 trim 子字典里) """ if not config: return None # trim 子字典 if "trim" in config and isinstance(config["trim"], dict): return TrimConfig.from_dict(config["trim"]) # 扁平字段 has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration")) if not has_any: return None data = { "start_time": config.get("trim_start", 0), "end_time": config.get("trim_end", 0), "duration": config.get("trim_duration", 0), } return TrimConfig.from_dict(data)