"""裁剪引擎 — 基于 FFmpeg trim/atrim 的精确帧级裁剪. 支持: - 入点出点裁剪(start_time / end_time / duration 三选二) - 边界自动钳制(超出素材时长自动修正,不阻断渲染) - 多段裁剪(一个素材裁剪出多段) - 音画同步(视频 + 音频同步裁剪) 领域模型已抽离到 packages/domain/trim_config.py,本模块保留薄包装。 """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any from packages.domain.trim_config import ( # noqa: F401 — 向后兼容 MIN_TRIM_DURATION as _min_trim_duration_base, TrimConfig, TrimSegment, ) logger = logging.getLogger(__name__) # 向后兼容:MIN_TRIM_DURATION 从 domain 层导出 MIN_TRIM_DURATION = _min_trim_duration_base # 向后兼容:extract_trim_from_clip_config 保留在本模块 def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None: """从 clip.config 中提取裁剪配置. 兼容多种字段命名: - trim: { start_time, end_time, duration } - 直接使用 start_time / end_time / duration 字段 """ if not config: return None # 优先使用 trim 子对象 trim_data = config.get("trim") if trim_data and isinstance(trim_data, dict): return TrimConfig.from_dict(trim_data) # 兼容:直接从 config 读取裁剪字段 has_trim_field = any( config.get(k) not in (None, 0, 0.0, "") for k in ("trim_start", "trim_end", "trim_duration", "start_time", "end_time", "duration") ) if not has_trim_field: return None # 映射字段名(支持 trim_ 前缀和无前缀两种) start = config.get("trim_start") or config.get("start_time") or 0 end = config.get("trim_end") or config.get("end_time") or 0 dur = config.get("trim_duration") or config.get("duration") or 0 return TrimConfig.from_dict( {"start_time": start, "end_time": end, "duration": dur} ) 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 []