6cbd08f666
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
- 修复 ruff 配置:移除已废弃规则 W503/E704(ruff 0.14+ 不兼容) - 修复 F541 (13处):f-string 无占位符改为普通字符串 - 修复 B017 (1处):pytest.raises(Exception) 改为 ValidationError - 修复 vulture 死代码:trim_engine 中 if False 的三元表达式 - 修复 mypy var-annotated:SUNSET_VERSIONS 加类型标注
340 lines
12 KiB
Python
Executable File
340 lines
12 KiB
Python
Executable File
"""裁剪引擎 — 基于 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)
|