33b2faf795
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
AI Code Review / AI Code Review (pull_request) Successful in 4m11s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [backend-only]
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m22s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
- 抽离 TrimConfig/TrimSegment 到 packages/domain/trim_config.py - 三选二推导 + 边界钳制 + 有效性判断纯逻辑全覆盖 - trim_engine.py 保留薄包装 + 滤镜构建,向后兼容 - 51个单测全绿,原有55个trim测试无回归
206 lines
6.8 KiB
Python
Executable File
206 lines
6.8 KiB
Python
Executable File
"""裁剪引擎 — 基于 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 []
|
|
|