Files
xiaoxia-saas/tests/unit/test_trim_engine.py
xiaoxia 241760ef39
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 37s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 4m21s
CI/CD Pipeline / Integration Tests (push) Failing after 4m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
feat: 水印 + 片头片尾引擎(视频包装能力) (#298)
2026-07-14 10:44:19 +08:00

269 lines
11 KiB
Python
Executable File
Raw Permalink 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()