Files
xiaoxia-saas/tests/unit/test_trim_engine.py
T
CI Bot 9fd3a469d7 feat: 视频裁剪/分割能力(Trimming Engine)
新增 TrimEngine 裁剪引擎,基于 FFmpeg trim/atrim 实现精确帧级裁剪:

核心能力:
- 入点出点裁剪:start_time / end_time / duration 三选二
- 边界自动钳制:超出素材时长自动修正,不阻断渲染
- 多段裁剪:一个素材可裁剪出多段(trim_segments)
- 音画同步:视频 + 音频同步裁剪

系统集成:
- UnifiedRenderService Step 1 接入视频裁剪(修复原有 filter_complex 路径未使用 start_time 的问题)
- render_audio 单 clip/多 clip/独立音轨 三处接入音频裁剪
- _resolve_clips 阶段解析裁剪配置 + 多段展开

129 个单测全绿(29 新增 + 100 现有)
2026-07-14 10:29:28 +08:00

269 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""裁剪引擎单元测试."""
import sys
import unittest
from pathlib import Path
# 确保 apps/worker 在路径中
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
from video_processing.trim_engine import (
MIN_TRIM_DURATION,
TrimConfig,
TrimEngine,
TrimSegment,
extract_trim_from_clip_config,
)
class TestTrimConfig(unittest.TestCase):
"""TrimConfig 单元测试."""
def test_from_dict_none(self):
"""空字典返回 None(不裁剪)."""
self.assertIsNone(TrimConfig.from_dict(None))
self.assertIsNone(TrimConfig.from_dict({}))
def test_from_dict_with_start(self):
"""只有 start_time."""
cfg = TrimConfig.from_dict({"start_time": 5.0})
self.assertIsNotNone(cfg)
self.assertEqual(cfg.start_time, 5.0)
self.assertEqual(cfg.end_time, 0.0)
self.assertEqual(cfg.duration, 0.0)
def test_from_dict_with_duration(self):
"""只有 duration."""
cfg = TrimConfig.from_dict({"duration": 10.0})
self.assertIsNotNone(cfg)
self.assertEqual(cfg.start_time, 0.0)
self.assertEqual(cfg.duration, 10.0)
def test_resolve_start_and_end(self):
"""start + end 推导 duration."""
cfg = TrimConfig(start_time=5.0, end_time=15.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 5.0)
self.assertEqual(resolved.end_time, 15.0)
self.assertAlmostEqual(resolved.duration, 10.0, places=3)
self.assertTrue(resolved.is_valid)
def test_resolve_start_and_duration(self):
"""start + duration 推导 end."""
cfg = TrimConfig(start_time=5.0, duration=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 5.0)
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
self.assertEqual(resolved.duration, 10.0)
def test_resolve_end_and_duration(self):
"""end + duration 推导 start."""
cfg = TrimConfig(end_time=20.0, duration=8.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertAlmostEqual(resolved.start_time, 12.0, places=3)
self.assertEqual(resolved.end_time, 20.0)
self.assertEqual(resolved.duration, 8.0)
def test_resolve_only_start(self):
"""只有 start → 取到末尾."""
cfg = TrimConfig(start_time=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 10.0)
self.assertEqual(resolved.end_time, 30.0)
self.assertAlmostEqual(resolved.duration, 20.0, places=3)
def test_resolve_only_duration(self):
"""只有 duration → 从开头取."""
cfg = TrimConfig(duration=15.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 0.0)
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
self.assertEqual(resolved.duration, 15.0)
def test_boundary_clamp_end(self):
"""end 超出素材时长 → 钳制."""
cfg = TrimConfig(start_time=5.0, duration=30.0)
resolved = cfg.validate_and_resolve(asset_duration=20.0)
self.assertEqual(resolved.start_time, 5.0)
self.assertEqual(resolved.end_time, 20.0)
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
def test_boundary_clamp_start_negative(self):
"""start 为负 → 钳制到 0."""
cfg = TrimConfig(start_time=-5.0, duration=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 0.0)
self.assertAlmostEqual(resolved.end_time, 10.0, places=3)
self.assertEqual(resolved.duration, 10.0)
def test_boundary_start_past_end(self):
"""start 超过素材总时长 → 钳制到末尾最小片段."""
cfg = TrimConfig(start_time=50.0, duration=5.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertTrue(resolved.start_time < 30.0)
self.assertEqual(resolved.end_time, 30.0)
self.assertTrue(resolved.duration >= MIN_TRIM_DURATION)
def test_invalid_end_before_start(self):
"""end <= start → 无效."""
cfg = TrimConfig(start_time=15.0, end_time=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertFalse(resolved.is_valid)
def test_zero_duration_invalid(self):
"""duration 为 0 → 无效."""
cfg = TrimConfig(start_time=5.0, duration=0.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
# 只有 start 没有 duration → 会被推导为取到末尾
self.assertTrue(resolved.is_valid)
self.assertEqual(resolved.end_time, 30.0)
def test_is_noop(self):
"""is_noop 判断."""
noop = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
self.assertTrue(noop.is_noop)
not_noop = TrimConfig(start_time=5.0, duration=10.0)
self.assertFalse(not_noop.is_noop)
def test_zero_asset_duration(self):
"""素材时长为 0 → 不裁剪."""
cfg = TrimConfig(start_time=5.0, duration=10.0)
resolved = cfg.validate_and_resolve(asset_duration=0.0)
self.assertTrue(resolved.is_noop)
def test_all_three_params_use_start_duration(self):
"""三个参数都给了 → 以 start + duration 为准."""
cfg = TrimConfig(start_time=5.0, end_time=20.0, duration=8.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
# validate_and_resolve 中 start+end 优先于 start+duration
# 因为先检查的是 start>0 and end>0
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
class TestTrimEngine(unittest.TestCase):
"""TrimEngine 单元测试."""
def test_build_video_trim_with_start_and_duration(self):
"""视频裁剪:start + duration."""
trim = TrimConfig(start_time=10.0, duration=5.0)
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
self.assertIn("trim=start=10.000:duration=5.000", result)
self.assertIn("setpts=PTS-STARTPTS", result)
self.assertTrue(result.startswith("[0:v]"))
self.assertTrue(result.endswith("[v0]"))
def test_build_video_trim_duration_only(self):
"""视频裁剪:只有 duration."""
trim = TrimConfig(start_time=0.0, duration=8.0)
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
self.assertIn("trim=duration=8.000", result)
self.assertNotIn("start=", result.split("setpts")[0])
def test_build_audio_trim_with_start(self):
"""音频裁剪:start + duration."""
trim = TrimConfig(start_time=3.0, duration=7.0)
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
self.assertIn("atrim=start=3.000:duration=7.000", result)
self.assertIn("asetpts=PTS-STARTPTS", result)
def test_build_audio_trim_noop(self):
"""音频裁剪:noop."""
trim = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
self.assertIn("asetpts=PTS-STARTPTS", result)
self.assertNotIn("atrim=", result)
def test_resolve_segments(self):
"""多段裁剪解析."""
segments = [
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=10.0, duration=5.0), order=1),
TrimSegment(segment_id="s3", trim=TrimConfig(start_time=20.0, duration=5.0), order=2),
]
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
self.assertEqual(len(resolved), 3)
self.assertEqual(resolved[0].segment_id, "s1")
self.assertEqual(resolved[0].trim.duration, 5.0)
self.assertEqual(resolved[1].segment_id, "s2")
self.assertEqual(resolved[1].trim.start_time, 10.0)
self.assertEqual(resolved[2].trim.start_time, 20.0)
def test_resolve_segments_filter_invalid(self):
"""多段裁剪:过滤无效段."""
segments = [
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
TrimSegment(segment_id="bad", trim=TrimConfig(start_time=10.0, end_time=5.0), order=1), # end < start
]
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
self.assertEqual(len(resolved), 1)
self.assertEqual(resolved[0].segment_id, "good")
def test_resolve_segments_boundary_clamp(self):
"""多段裁剪:边界钳制."""
segments = [
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=25.0, duration=10.0), order=0),
]
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
self.assertEqual(len(resolved), 1)
self.assertEqual(resolved[0].trim.end_time, 30.0)
self.assertAlmostEqual(resolved[0].trim.duration, 5.0, places=3)
def test_parse_segments_from_list(self):
"""从 config 解析多段配置."""
config = {
"trim_segments": [
{"segment_id": "intro", "start_time": 0, "duration": 3, "order": 0},
{"segment_id": "highlight", "start_time": 10, "duration": 5, "order": 1},
{"segment_id": "outro", "start_time": 50, "duration": 3, "order": 2},
]
}
segments = TrimEngine.parse_segments_from_config(config)
self.assertEqual(len(segments), 3)
self.assertEqual(segments[0].segment_id, "intro")
self.assertEqual(segments[1].trim.start_time, 10.0)
self.assertEqual(segments[2].trim.duration, 3.0)
def test_parse_segments_empty(self):
"""无裁剪配置 → 空列表."""
self.assertEqual(TrimEngine.parse_segments_from_config(None), [])
self.assertEqual(TrimEngine.parse_segments_from_config({}), [])
def test_parse_single_trim_legacy(self):
"""旧格式单段裁剪(trim_start/trim_duration."""
config = {"trim_start": 5.0, "trim_duration": 10.0}
segments = TrimEngine.parse_segments_from_config(config)
self.assertEqual(len(segments), 1)
self.assertEqual(segments[0].trim.start_time, 5.0)
self.assertEqual(segments[0].trim.duration, 10.0)
class TestExtractTrimFromClipConfig(unittest.TestCase):
"""extract_trim_from_clip_config 单元测试."""
def test_trim_subdict(self):
"""trim 子字典."""
config = {"trim": {"start_time": 5.0, "duration": 10.0}}
result = extract_trim_from_clip_config(config)
self.assertIsNotNone(result)
self.assertEqual(result.start_time, 5.0)
self.assertEqual(result.duration, 10.0)
def test_flat_fields(self):
"""扁平字段(trim_start/trim_end/trim_duration."""
config = {"trim_start": 2.0, "trim_end": 8.0}
result = extract_trim_from_clip_config(config)
self.assertIsNotNone(result)
self.assertEqual(result.start_time, 2.0)
self.assertEqual(result.end_time, 8.0)
def test_no_trim(self):
"""无裁剪配置."""
self.assertIsNone(extract_trim_from_clip_config(None))
self.assertIsNone(extract_trim_from_clip_config({}))
self.assertIsNone(extract_trim_from_clip_config({"other": "value"}))
if __name__ == "__main__":
unittest.main()