1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
- 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL
2. pyflakes 警告清零 (apps/ + packages/ + tests/):
- 移除 17 处未使用的 import (F401)
- 修复 26 处未使用的局部变量 (F841):
* 有副作用的赋值转为裸调用
* 无副作用的赋值直接删除
- 修复 1 处未使用的异常变量 (F841)
- 修复 1 处空 except 块
3. 测试文件冗余清理:
- 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
- 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
376 lines
15 KiB
Python
376 lines
15 KiB
Python
"""P0-2 / P0-3 修复验证测试.
|
||
|
||
P0-2: OSSStorageService.get_download_url 预签名 URL 逻辑验证
|
||
P0-3: FFmpeg xfade exit 234 — build_xfade_filter_chain 安全钳制 + effective_duration trim
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||
|
||
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
|
||
|
||
|
||
class TestBuildXfadeFilterChainSafetyClamp:
|
||
"""验证 xfade 滤镜链的安全钳制逻辑,防止 exit 234。"""
|
||
|
||
def test_empty_clips(self):
|
||
"""空片段列表返回空字符串和 0 时长。"""
|
||
f, dur = build_xfade_filter_chain([], [], [])
|
||
assert f == ""
|
||
assert dur == 0.0
|
||
|
||
def test_single_clip(self):
|
||
"""单片段用 copy,不做 xfade。"""
|
||
f, dur = build_xfade_filter_chain([3.0], ["v0"], ["cut"])
|
||
assert "copy" in f
|
||
assert dur == 3.0
|
||
|
||
def test_two_clips_normal(self):
|
||
"""两个正常时长片段 — offset + td ≤ first_input_duration。"""
|
||
f, 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 f
|
||
assert "duration=0.500" in f
|
||
# 总时长 = 5 + 5 - 0.5 = 9.5
|
||
assert abs(dur - 9.5) < 0.01
|
||
|
||
def test_short_clip_safety_clamp(self):
|
||
"""片段短于 transition_duration 时,td 被钳制,不会导致 exit 234。
|
||
|
||
这是 P0-3 的核心场景:视频只有 1s,td=0.5s,offset 计算后
|
||
offset + td 可能超过 first_input_duration。
|
||
"""
|
||
# 两个 1s 片段,td=0.5s
|
||
# 原始 offset = max(0, 1.0 - 0.5*1) = 0.5
|
||
# first_input_dur = 1.0, available = 1.0 - 0.5 = 0.5
|
||
# safe_td = min(0.5, 0.5) = 0.5 — 刚好 OK
|
||
f, dur = build_xfade_filter_chain(
|
||
clip_durations=[1.0, 1.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
assert "xfade" in f
|
||
# offset + td 必须 ≤ first_input_duration
|
||
# offset=0.5, td=0.5 → 0.5+0.5=1.0 ≤ 1.0 ✓
|
||
assert dur > 0
|
||
|
||
def test_very_short_clip_clamped(self):
|
||
"""片段极短(0.3s),td 被钳制到 available 以内。"""
|
||
# clip1=0.3s, clip2=5.0s, td=0.5s
|
||
# offset = max(0, 0.3 - 0.5) = 0.0
|
||
# first_input_dur = 0.3
|
||
# available = 0.3 - 0.0 = 0.3
|
||
# safe_td = min(0.5, 0.3) = 0.3
|
||
f, dur = build_xfade_filter_chain(
|
||
clip_durations=[0.3, 5.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
assert "duration=0.300" in f # td 被钳制到 0.3
|
||
|
||
def test_multi_clip_chain_clamp(self):
|
||
"""多片段链式 xfade,每步都钳制。"""
|
||
# 3 个 0.5s 片段,td=0.5s
|
||
# Step 1: offset=0, first_input_dur=0.5, available=0.5, safe_td=0.5
|
||
# total_transition=0.5
|
||
# Step 2: cumulative=1.0, first_input_dur=1.0-0.5=0.5
|
||
# offset=max(0, 1.0-0.5*2)=0.0, available=0.5, safe_td=0.5
|
||
f, dur = build_xfade_filter_chain(
|
||
clip_durations=[0.5, 0.5, 0.5],
|
||
clip_video_labels=["v0", "v1", "v2"],
|
||
transitions=["cut", "fade", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
assert "xfade" in f
|
||
assert f.count("xfade") == 2
|
||
assert dur > 0
|
||
|
||
def test_middle_clip_shorter_than_td(self):
|
||
"""P1 修复验证:中间片段短于 td 时,td 被钳制到该片段时长。
|
||
|
||
[5.0, 0.3, 5.0] + td=0.5s:
|
||
- Step 1: second input = 0.3s, safe_td 必须 ≤ 0.3
|
||
- Step 2: second input = 5.0s, safe_td 可以 = 0.5
|
||
"""
|
||
import re
|
||
|
||
f, dur = build_xfade_filter_chain(
|
||
clip_durations=[5.0, 0.3, 5.0],
|
||
clip_video_labels=["v0", "v1", "v2"],
|
||
transitions=["cut", "fade", "fade"],
|
||
transition_duration=0.5,
|
||
)
|
||
assert f.count("xfade") == 2
|
||
|
||
# 解析每个 xfade 的 duration
|
||
durations_found = []
|
||
for part in f.split(";"):
|
||
if "xfade=" not in part:
|
||
continue
|
||
m = re.search(r"duration=([\d.]+)", part)
|
||
assert m, f"无法解析: {part}"
|
||
durations_found.append(float(m.group(1)))
|
||
|
||
# 第一个 xfade: td 必须 ≤ 0.3 (第二个输入 clip_durations[1]=0.3)
|
||
assert durations_found[0] <= 0.3 + 0.001, f"第一个 xfade td={durations_found[0]} 超过 clip_durations[1]=0.3"
|
||
# 第二个 xfade: td 可以 = 0.5 (clip_durations[2]=5.0)
|
||
assert durations_found[1] <= 0.5 + 0.001
|
||
assert dur > 0
|
||
|
||
def test_offset_plus_td_never_exceeds_input(self):
|
||
"""压力测试:多种时长组合,offset + td 永远不超过 first_input_duration。"""
|
||
test_cases = [
|
||
([0.1, 5.0], 0.5),
|
||
([0.5, 0.5], 0.5),
|
||
([1.0, 1.0, 1.0], 0.5),
|
||
([0.2, 0.3, 0.4], 0.5),
|
||
([10.0, 0.1], 0.5),
|
||
([3.0, 3.0, 3.0, 3.0], 0.5),
|
||
([5.0, 0.3, 5.0], 0.5), # P1: 中间片段短于 td
|
||
([5.0, 0.1, 0.1, 5.0], 0.5), # P1: 多个中间片段都短于 td
|
||
]
|
||
for durations, td in test_cases:
|
||
labels = [f"v{i}" for i in range(len(durations))]
|
||
transitions = ["cut"] + ["fade"] * (len(durations) - 1)
|
||
f, dur = build_xfade_filter_chain(
|
||
clip_durations=durations,
|
||
clip_video_labels=labels,
|
||
transitions=transitions,
|
||
transition_duration=td,
|
||
)
|
||
assert dur >= 0, f" durations={durations} td={td} → dur={dur}"
|
||
# 解析 filter 验证 offset + td 的合理性
|
||
import re
|
||
|
||
# 按 xfade 步骤索引追踪第二个输入
|
||
xfade_idx = 0
|
||
for part in f.split(";"):
|
||
if "xfade=" not in part:
|
||
continue
|
||
# 格式: [prev][next]xfade=transition=X:duration=D:offset=O[out]
|
||
m = re.search(
|
||
r"xfade=transition=(\w+):duration=([\d.]+):offset=([\d.]+)",
|
||
part,
|
||
)
|
||
assert m, f"无法解析 xfade 参数: {part}"
|
||
offset_val = float(m.group(3))
|
||
dur_val = float(m.group(2))
|
||
assert offset_val >= 0
|
||
assert dur_val >= 0.001 # 至少 1ms
|
||
# P1 修复验证: td 不能超过第二个输入片段时长
|
||
second_input_idx = xfade_idx + 1
|
||
assert (
|
||
dur_val <= durations[second_input_idx] + 0.001
|
||
), f"td={dur_val} > clip_durations[{second_input_idx}]={durations[second_input_idx]}"
|
||
xfade_idx += 1
|
||
|
||
|
||
# ── P0-3: effective_duration trim 逻辑验证 ────────────────────────────────────
|
||
|
||
|
||
class TestEffectiveDurationTrim:
|
||
"""验证 _build_filter_complex 中 effective_duration trim 逻辑。"""
|
||
|
||
def _make_service(self, clips, asset_paths=None):
|
||
from pathlib import Path
|
||
|
||
from video_processing.unified_render_service import UnifiedRenderService
|
||
|
||
plan = MagicMock()
|
||
plan.id = "test_plan"
|
||
work_dir = Path("/tmp/test_render")
|
||
if asset_paths is None:
|
||
asset_paths = {}
|
||
for c in clips:
|
||
asset_paths[c.asset_id] = Path(f"/tmp/{c.asset_id}")
|
||
return UnifiedRenderService(
|
||
plan=plan,
|
||
clips=clips,
|
||
asset_path_map=asset_paths,
|
||
work_dir=work_dir,
|
||
)
|
||
|
||
def _make_clip(self, clip_id, duration=0.0, actual_duration=5.0, clip_type="main", order=0):
|
||
from pathlib import Path
|
||
|
||
from video_processing.unified_render_service import ResolvedClip
|
||
|
||
return ResolvedClip(
|
||
clip_id=clip_id,
|
||
asset_id=f"asset_{clip_id}.mp4",
|
||
local_path=Path(f"/tmp/asset_{clip_id}.mp4"),
|
||
clip_type=clip_type,
|
||
order=order,
|
||
duration=duration,
|
||
actual_duration=actual_duration,
|
||
transition_effect="fade",
|
||
config={},
|
||
)
|
||
|
||
def test_trim_applied_when_duration_less_than_actual(self):
|
||
"""clip.duration < actual_duration → trim=duration=clip.duration。"""
|
||
clip = self._make_clip("c1", duration=3.0, actual_duration=10.0)
|
||
svc = self._make_service([clip])
|
||
layers = svc._group_clips_into_layers([clip])
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
assert "trim=duration=3.0" in fc
|
||
|
||
def test_trim_uses_actual_when_no_duration_set(self):
|
||
"""clip.duration=0 → 使用 actual_duration 做 trim。"""
|
||
clip = self._make_clip("c1", duration=0.0, actual_duration=7.5)
|
||
svc = self._make_service([clip])
|
||
layers = svc._group_clips_into_layers([clip])
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
assert "trim=duration=7.5" in fc
|
||
|
||
def test_trim_uses_min_of_duration_and_actual(self):
|
||
"""clip.duration > actual_duration → trim 到 actual_duration。"""
|
||
clip = self._make_clip("c1", duration=10.0, actual_duration=2.0)
|
||
svc = self._make_service([clip])
|
||
layers = svc._group_clips_into_layers([clip])
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
assert "trim=duration=2.0" in fc
|
||
|
||
def test_no_trim_when_both_zero(self):
|
||
"""duration=0 且 actual_duration=0 → 不做 trim。"""
|
||
clip = self._make_clip("c1", duration=0.0, actual_duration=0.0)
|
||
svc = self._make_service([clip])
|
||
layers = svc._group_clips_into_layers([clip])
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
assert "trim=" not in fc
|
||
|
||
def test_xfade_uses_effective_durations(self):
|
||
"""多片段 xfade 使用 trim 后的有效时长。"""
|
||
clip1 = self._make_clip("c1", duration=3.0, actual_duration=10.0, order=0)
|
||
clip2 = self._make_clip("c2", duration=4.0, actual_duration=10.0, order=1)
|
||
svc = self._make_service([clip1, clip2])
|
||
layers = svc._group_clips_into_layers([clip1, clip2])
|
||
fc, _ = svc._build_filter_complex(layers)
|
||
assert "xfade=" in fc
|
||
# 两个 clip 的 trim 应该分别用 3.0 和 4.0
|
||
assert "trim=duration=3.0" in fc
|
||
assert "trim=duration=4.0" in fc
|
||
|
||
|
||
# ── P0-2: get_download_url 预签名 URL 逻辑验证 ────────────────────────────────
|
||
|
||
|
||
class TestGetDownloadUrl:
|
||
"""验证 OSSStorageService.get_download_url 逻辑。"""
|
||
|
||
def test_returns_signed_url_when_bucket_configured(self):
|
||
"""bucket 已配置 → 返回签名 URL。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
svc.bucket = MagicMock()
|
||
svc.bucket.sign_url.return_value = "https://signed-url.oss.com/file.mp4?signature=xxx"
|
||
svc.public_url = "https://bucket.oss.com"
|
||
|
||
result = svc.get_download_url("uploads/video.mp4")
|
||
|
||
svc.bucket.sign_url.assert_called_once_with("GET", "uploads/video.mp4", 3600)
|
||
assert "signed-url" in result
|
||
|
||
def test_returns_raw_url_when_bucket_none(self):
|
||
"""bucket 未配置 → 返回原始公网 URL,并记录 warning。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
svc.bucket = None
|
||
svc.public_url = "https://bucket.oss.com"
|
||
svc.local_url_prefix = "/generated-files"
|
||
|
||
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
|
||
result = svc.get_download_url("uploads/video.mp4")
|
||
|
||
assert result == "https://bucket.oss.com/uploads/video.mp4"
|
||
|
||
def test_local_generated_url_returned_as_is(self):
|
||
"""本地生成文件 URL → 原样返回,不走 OSS。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
svc.bucket = None
|
||
svc.local_url_prefix = "/generated-files"
|
||
|
||
result = svc.get_download_url("/generated-files/abc123.mp4")
|
||
assert result == "/generated-files/abc123.mp4"
|
||
|
||
def test_normalize_strips_full_url(self):
|
||
"""完整 URL → 提取路径部分作为 storage_key。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
key = svc._normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/video.mp4")
|
||
assert key == "uploads/video.mp4"
|
||
|
||
def test_normalize_preserves_plain_key(self):
|
||
"""纯路径 → 保持不变。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
key = svc._normalize_storage_key("uploads/video.mp4")
|
||
assert key == "uploads/video.mp4"
|
||
|
||
def test_sign_url_failure_falls_back(self):
|
||
"""sign_url 异常 → 回退到原始 URL,不崩溃。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
svc.bucket = MagicMock()
|
||
svc.bucket.sign_url.side_effect = Exception("OSS error")
|
||
svc.public_url = "https://bucket.oss.com"
|
||
|
||
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
|
||
result = svc.get_download_url("uploads/video.mp4")
|
||
|
||
assert result == "https://bucket.oss.com/uploads/video.mp4"
|
||
|
||
def test_diagnostic_logging_on_bucket_none(self, caplog):
|
||
"""bucket 未配置时记录 warning 日志。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
svc.bucket = None
|
||
svc.public_url = "https://bucket.oss.com"
|
||
svc.local_url_prefix = "/generated-files"
|
||
|
||
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
|
||
with caplog.at_level(logging.WARNING):
|
||
svc.get_download_url("https://bucket.oss.com/uploads/video.mp4")
|
||
|
||
assert any("OSS bucket not configured" in r.message for r in caplog.records)
|
||
|
||
def test_diagnostic_logging_on_sign_success(self, caplog):
|
||
"""签名成功时记录 info 日志。"""
|
||
from app.core.storage import OSSStorageService
|
||
|
||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||
svc = OSSStorageService()
|
||
svc.bucket = MagicMock()
|
||
svc.bucket.sign_url.return_value = "https://signed.oss.com/file.mp4?sig=xxx"
|
||
svc.public_url = "https://bucket.oss.com"
|
||
|
||
with caplog.at_level(logging.INFO):
|
||
svc.get_download_url("uploads/video.mp4")
|
||
|
||
assert any("signed URL generated" in r.message for r in caplog.records)
|