44224cfaf6
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 18s
CI/CD Pipeline / Validate - Style (push) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m56s
CI/CD Pipeline / Validate - Security (push) Successful in 5m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m33s
CI/CD Pipeline / Unit Tests (push) Failing after 9m24s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 196h56m39s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 196h56m42s
CI/CD Pipeline / Deploy Production (push) Failing after 196h56m39s
CI/CD Pipeline / Build Production Web Image (push) Failing after 196h56m42s
CI/CD Pipeline / Build Production API Image (push) Failing after 196h56m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 197h1m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 197h4m37s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 197h4m37s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 197h4m38s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 197h4m53s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 197h4m56s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 197h5m11s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 197h5m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 197h5m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 197h5m16s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 197h5m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 197h5m16s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 197h5m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 197h6m1s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 197h5m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 197h6m3s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 197h6m3s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 197h6m4s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 197h6m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 197h6m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 197h6m7s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 197h6m7s
CI/CD Pipeline / PR Build API Image (push) Failing after 197h6m7s
CI/CD Pipeline / Frontend Lint (push) Failing after 197h6m7s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 197h6m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 197h6m17s
CI/CD Pipeline / CI Gate (push) Failing after 197h31m25s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 197h39m53s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 197h39m58s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 197h40m45s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 197h40m47s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 197h40m51s
CI/CD Pipeline / PR Build Web Image (push) Failing after 197h40m51s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
262 lines
9.2 KiB
Python
262 lines
9.2 KiB
Python
"""#1766 xfade_builder 逐转场时长与位置微调测试.
|
||
|
||
覆盖:
|
||
- build_xfade_filter_chain:transition_durations 参数(逐转场独立时长)
|
||
- build_xfade_filter_chain:jitters 参数(位置微调偏移)
|
||
- 向后兼容:不传新参数时行为不变
|
||
- TransitionEngine.build_xfade_chain:透传新参数
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
for sub in ("packages", ""):
|
||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||
if p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from packages.domain.xfade_builder import ( # noqa: E402
|
||
DEFAULT_TRANSITION_DURATION,
|
||
build_xfade_filter_chain,
|
||
)
|
||
|
||
|
||
class TestBackwardCompatibility:
|
||
"""向后兼容测试:不传新参数时行为不变。"""
|
||
|
||
def test_single_transition_duration(self):
|
||
"""全局 transition_duration 仍有效。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
assert "xfade" in result
|
||
assert "duration=0.500" in result
|
||
assert dur > 0
|
||
|
||
def test_default_transition_duration(self):
|
||
"""不传 transition_duration 时使用默认值 0.5。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
)
|
||
assert "duration=0.500" in result
|
||
|
||
|
||
class TestPerTransitionDurations:
|
||
"""逐转场独立时长测试。"""
|
||
|
||
def test_different_durations_per_transition(self):
|
||
"""每个转场使用不同的时长。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0, 5.0],
|
||
clip_video_labels=["v0", "v1", "v2"],
|
||
transitions=["cut", "fade", "dissolve"],
|
||
transition_durations=[0.3, 0.8], # 第 1 个转场 0.3s,第 2 个 0.8s
|
||
)
|
||
assert "duration=0.300" in result
|
||
assert "duration=0.800" in result
|
||
|
||
def test_partial_durations_fallback_to_global(self):
|
||
"""transition_durations 长度不足时 fallback 到 transition_duration。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0, 5.0],
|
||
clip_video_labels=["v0", "v1", "v2"],
|
||
transitions=["cut", "fade", "dissolve"],
|
||
transition_duration=0.5,
|
||
transition_durations=[0.3], # 只有第一个,第二个 fallback 到 0.5
|
||
)
|
||
assert "duration=0.300" in result
|
||
assert "duration=0.500" in result
|
||
|
||
def test_empty_durations_uses_global(self):
|
||
"""transition_durations=[] 时使用全局 transition_duration。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.6,
|
||
transition_durations=[],
|
||
)
|
||
assert "duration=0.600" in result
|
||
|
||
def test_none_durations_uses_global(self):
|
||
"""transition_durations=None 时使用全局 transition_duration。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.6,
|
||
transition_durations=None,
|
||
)
|
||
assert "duration=0.600" in result
|
||
|
||
def test_duration_clamped_by_clip_length(self):
|
||
"""转场时长不能超过相邻片段时长。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[2.0, 2.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_durations=[1.5], # 1.5s > 2.0s * 0.4,会被钳制
|
||
)
|
||
# 钳制到可用范围内
|
||
assert "duration=" in result
|
||
|
||
def test_total_duration_reflects_per_transition(self):
|
||
"""总时长反映逐转场的重叠量。"""
|
||
# 使用 0.3s 转场
|
||
_, dur_short = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_durations=[0.3],
|
||
)
|
||
# 使用 0.8s 转场
|
||
_, dur_long = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_durations=[0.8],
|
||
)
|
||
# 更长转场 → 更多重叠 → 总时长更短
|
||
assert dur_long < dur_short
|
||
|
||
|
||
class TestJitters:
|
||
"""位置微调 jitter 测试。"""
|
||
|
||
def test_positive_jitter_delays_transition(self):
|
||
"""正 jitter 推迟转场(offset 增大)。"""
|
||
result_no_jitter, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
result_with_jitter, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
jitters=[0.3], # 正 jitter:推迟转场
|
||
)
|
||
# 提取 offset 值
|
||
import re
|
||
|
||
offset_no = float(re.search(r"offset=([\d.]+)", result_no_jitter).group(1))
|
||
offset_yes = float(re.search(r"offset=([\d.]+)", result_with_jitter).group(1))
|
||
assert offset_yes > offset_no
|
||
|
||
def test_negative_jitter_advances_transition(self):
|
||
"""负 jitter 提前转场(offset 减小)。"""
|
||
result_no_jitter, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
result_with_jitter, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
jitters=[-0.3], # 负 jitter:提前转场
|
||
)
|
||
import re
|
||
|
||
offset_no = float(re.search(r"offset=([\d.]+)", result_no_jitter).group(1))
|
||
offset_yes = float(re.search(r"offset=([\d.]+)", result_with_jitter).group(1))
|
||
assert offset_yes < offset_no
|
||
|
||
def test_jitter_clamped_to_valid_range(self):
|
||
"""jitter 不会使 offset 超出有效范围(>=0)。"""
|
||
result, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
jitters=[-100.0], # 极大负 jitter
|
||
)
|
||
import re
|
||
|
||
offset = float(re.search(r"offset=([\d.]+)", result).group(1))
|
||
assert offset >= 0.0
|
||
|
||
def test_per_transition_jitters(self):
|
||
"""每个转场可以有独立的 jitter。"""
|
||
result, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0, 5.0],
|
||
clip_video_labels=["v0", "v1", "v2"],
|
||
transitions=["cut", "fade", "dissolve"],
|
||
transition_duration=0.5,
|
||
jitters=[0.2, -0.1],
|
||
)
|
||
import re
|
||
|
||
offsets = [float(m.group(1)) for m in re.finditer(r"offset=([\d.]+)", result)]
|
||
assert len(offsets) == 2
|
||
|
||
def test_empty_jitters_no_effect(self):
|
||
"""jitters=[] 等同于无 jitter。"""
|
||
result_no, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
result_empty, _ = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
jitters=[],
|
||
)
|
||
assert result_no == result_empty
|
||
|
||
|
||
class TestEdgeCases:
|
||
"""边界情况测试。"""
|
||
|
||
def test_single_clip_with_durations(self):
|
||
"""单片段传入 transition_durations 不报错。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0],
|
||
clip_video_labels=["v0"],
|
||
transitions=["cut"],
|
||
transition_durations=[0.5],
|
||
)
|
||
assert "copy" in result
|
||
assert dur == 5.0
|
||
|
||
def test_empty_clips(self):
|
||
"""空片段列表返回空字符串。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[],
|
||
clip_video_labels=[],
|
||
transitions=[],
|
||
transition_durations=[],
|
||
jitters=[],
|
||
)
|
||
assert result == ""
|
||
assert dur == 0.0
|
||
|
||
def test_all_cut_transitions(self):
|
||
"""全硬切场景(不进入 xfade,由调用方处理 concat)。"""
|
||
result, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "cut"],
|
||
transition_durations=[0.0, 0.0],
|
||
)
|
||
# cut 转场仍然会生成 xfade 滤镜(因为底层不区分 cut)
|
||
# 调用方(unified_render_service)负责检测全硬切并走 concat
|
||
assert dur > 0
|