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>
618 lines
22 KiB
Python
618 lines
22 KiB
Python
"""VideoComposeService 单元测试.
|
|
|
|
使用 stub 仓储替代真实数据库,测试 FFmpeg 命令生成和校验逻辑。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest import TestCase
|
|
|
|
# 修正 import 路径
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
|
|
|
from app.services.video_compose_service import (
|
|
ComposeCommand,
|
|
VideoComposeService,
|
|
_build_concat_filter,
|
|
_build_xfade_filter,
|
|
_chain_filters,
|
|
)
|
|
|
|
from packages.domain.edit_plan import EditPlanStatus
|
|
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
|
|
|
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _StubClip:
|
|
"""EditPlanClip 的轻量替身。"""
|
|
|
|
def __init__(
|
|
self,
|
|
clip_id: str = "clip-1",
|
|
plan_id: str = "plan-1",
|
|
clip_type: str = "main",
|
|
order: int = 0,
|
|
asset_id: str = "",
|
|
duration: float = 5.0,
|
|
start_time: float = 0.0,
|
|
transition_effect: str = "cut",
|
|
status: EditPlanClipStatus = EditPlanClipStatus.READY,
|
|
):
|
|
self.id = clip_id
|
|
self.plan_id = plan_id
|
|
self.clip_type = clip_type
|
|
self.order = order
|
|
self.asset_id = asset_id
|
|
self.duration = duration
|
|
self.start_time = start_time
|
|
self.transition_effect = transition_effect
|
|
self.status = status
|
|
self.template_clip_config_id = ""
|
|
self.text_content = ""
|
|
self.config = {}
|
|
|
|
|
|
class _StubPlan:
|
|
"""EditPlan 的轻量替身。"""
|
|
|
|
def __init__(
|
|
self,
|
|
plan_id: str = "plan-1",
|
|
status: EditPlanStatus = EditPlanStatus.EDITING,
|
|
):
|
|
self.id = plan_id
|
|
self.template_id = "tpl-1"
|
|
self.name = "测试计划"
|
|
self.status = status
|
|
self.total_duration = 0.0
|
|
self.config = {}
|
|
|
|
|
|
# ── Stub 仓储 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _StubPlanRepo:
|
|
def __init__(self, plans: dict[str, _StubPlan] | None = None):
|
|
self._plans = plans or {}
|
|
|
|
def get(self, plan_id: str):
|
|
return self._plans.get(plan_id)
|
|
|
|
def list_by_project(self, *args, **kwargs):
|
|
return list(self._plans.values())
|
|
|
|
|
|
class _StubClipRepo:
|
|
def __init__(self, clips: list[_StubClip] | None = None):
|
|
self._clips = {c.id: c for c in (clips or [])}
|
|
self._by_plan: dict[str, list[_StubClip]] = {}
|
|
for c in clips or []:
|
|
self._by_plan.setdefault(c.plan_id, []).append(c)
|
|
|
|
def get(self, clip_id: str):
|
|
return self._clips.get(clip_id)
|
|
|
|
def list_by_plan(self, plan_id: str, skip: int = 0, limit: int = 100):
|
|
clips = self._by_plan.get(plan_id, [])
|
|
return clips[skip : skip + limit]
|
|
|
|
|
|
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_service(
|
|
plan: _StubPlan | None = None,
|
|
clips: list[_StubClip] | None = None,
|
|
) -> VideoComposeService:
|
|
"""创建注入 stub 仓储的 VideoComposeService。"""
|
|
svc = VideoComposeService.__new__(VideoComposeService)
|
|
svc._db = None # type: ignore[assignment]
|
|
svc._plan_repo = _StubPlanRepo({plan.id: plan} if plan else {}) # type: ignore[assignment]
|
|
svc._clip_repo = _StubClipRepo(clips or []) # type: ignore[assignment]
|
|
return svc
|
|
|
|
|
|
def _make_ready_clip(
|
|
clip_id: str = "clip-1",
|
|
plan_id: str = "plan-1",
|
|
order: int = 0,
|
|
duration: float = 5.0,
|
|
asset_id: str = "assets/video.mp4",
|
|
transition: str = "cut",
|
|
) -> _StubClip:
|
|
return _StubClip(
|
|
clip_id=clip_id,
|
|
plan_id=plan_id,
|
|
order=order,
|
|
asset_id=asset_id,
|
|
duration=duration,
|
|
transition_effect=transition,
|
|
status=EditPlanClipStatus.READY,
|
|
)
|
|
|
|
|
|
# ── 测试用例 ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestValidateCompose(TestCase):
|
|
"""validate_compose 校验逻辑测试。"""
|
|
|
|
def test_plan_not_found(self):
|
|
"""计划不存在 → 校验失败。"""
|
|
svc = _make_service()
|
|
result = svc.validate_compose("nonexistent")
|
|
self.assertFalse(result.valid)
|
|
self.assertIn("剪辑计划不存在", result.errors[0])
|
|
|
|
def test_wrong_status(self):
|
|
"""计划状态不是 editing/rendering → 校验失败。"""
|
|
plan = _StubPlan(status=EditPlanStatus.DRAFT)
|
|
clips = [_make_ready_clip()]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("状态不正确" in e for e in result.errors))
|
|
|
|
def test_no_clips(self):
|
|
"""计划没有片段 → 校验失败。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
svc = _make_service(plan, [])
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertFalse(result.valid)
|
|
self.assertIn("计划没有任何片段", result.errors[0])
|
|
|
|
def test_no_ready_clips(self):
|
|
"""没有 ready 状态的片段 → 校验失败。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
clips = [
|
|
_StubClip(
|
|
clip_id="c1",
|
|
plan_id=plan.id,
|
|
status=EditPlanClipStatus.PENDING,
|
|
asset_id="a.mp4",
|
|
)
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("没有就绪" in e for e in result.errors))
|
|
|
|
def test_ready_clip_without_asset(self):
|
|
"""ready 片段没有 asset_id → 校验失败。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
clips = [
|
|
_StubClip(
|
|
clip_id="c1",
|
|
plan_id=plan.id,
|
|
status=EditPlanClipStatus.READY,
|
|
asset_id="",
|
|
)
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertFalse(result.valid)
|
|
self.assertTrue(any("没有分配素材" in e for e in result.errors))
|
|
|
|
def test_valid_single_clip(self):
|
|
"""单个 ready 片段 → 校验通过。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
clips = [_make_ready_clip(plan_id=plan.id)]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertTrue(result.valid)
|
|
self.assertEqual(result.ready_clip_count, 1)
|
|
self.assertEqual(result.total_clip_count, 1)
|
|
self.assertEqual(len(result.errors), 0)
|
|
|
|
def test_valid_multiple_clips(self):
|
|
"""多个 ready 片段 → 校验通过。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
clips = [
|
|
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0),
|
|
_make_ready_clip(clip_id="c2", plan_id=plan.id, order=1),
|
|
_make_ready_clip(clip_id="c3", plan_id=plan.id, order=2),
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertTrue(result.valid)
|
|
self.assertEqual(result.ready_clip_count, 3)
|
|
|
|
def test_rendering_status_also_valid(self):
|
|
"""rendering 状态也允许合成。"""
|
|
plan = _StubPlan(status=EditPlanStatus.RENDERING)
|
|
clips = [_make_ready_clip(plan_id=plan.id)]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertTrue(result.valid)
|
|
|
|
def test_mixed_statuses_with_pending_warning(self):
|
|
"""混合状态:ready + pending → 通过但有警告。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
clips = [
|
|
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0),
|
|
_StubClip(
|
|
clip_id="c2",
|
|
plan_id=plan.id,
|
|
order=1,
|
|
status=EditPlanClipStatus.PENDING,
|
|
asset_id="b.mp4",
|
|
),
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
result = svc.validate_compose(plan.id)
|
|
self.assertTrue(result.valid)
|
|
self.assertEqual(result.ready_clip_count, 1)
|
|
self.assertEqual(result.total_clip_count, 2)
|
|
self.assertTrue(any("pending" in w for w in result.warnings))
|
|
|
|
|
|
class TestBuildComposeCommand(TestCase):
|
|
"""build_compose_command 命令生成测试。"""
|
|
|
|
def test_plan_not_found_raises(self):
|
|
"""计划不存在 → ValueError。"""
|
|
svc = _make_service()
|
|
with self.assertRaises(ValueError):
|
|
svc.build_compose_command("nonexistent", "/tmp/out.mp4")
|
|
|
|
def test_no_clips_raises(self):
|
|
"""没有片段 → ValueError。"""
|
|
plan = _StubPlan()
|
|
svc = _make_service(plan, [])
|
|
with self.assertRaises(ValueError):
|
|
svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
def test_no_ready_clips_raises(self):
|
|
"""没有 ready 片段 → ValueError。"""
|
|
plan = _StubPlan()
|
|
clips = [
|
|
_StubClip(
|
|
clip_id="c1",
|
|
plan_id=plan.id,
|
|
status=EditPlanClipStatus.PENDING,
|
|
asset_id="a.mp4",
|
|
)
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
with self.assertRaises(ValueError):
|
|
svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
def test_single_clip_command(self):
|
|
"""单片段命令生成。"""
|
|
plan = _StubPlan()
|
|
clips = [_make_ready_clip(plan_id=plan.id, duration=10.0)]
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
self.assertIsInstance(cmd, ComposeCommand)
|
|
self.assertEqual(cmd.input_paths, ["assets/video.mp4"])
|
|
self.assertEqual(cmd.output_path, "/tmp/out.mp4")
|
|
self.assertEqual(cmd.estimated_duration, 10.0)
|
|
self.assertEqual(len(cmd.clip_chains), 1)
|
|
self.assertIn("ffmpeg", cmd.command[0])
|
|
self.assertIn("-filter_complex", cmd.command)
|
|
|
|
def test_multi_clip_concat_command(self):
|
|
"""多片段 concat 命令生成。"""
|
|
plan = _StubPlan()
|
|
clips = [
|
|
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0),
|
|
_make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0),
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
self.assertEqual(len(cmd.input_paths), 2)
|
|
self.assertEqual(cmd.estimated_duration, 13.0)
|
|
self.assertIn("concat", cmd.filter_complex)
|
|
self.assertIn("[outv]", cmd.filter_complex)
|
|
|
|
def test_multi_clip_xfade_command(self):
|
|
"""多片段 xfade 转场命令生成。"""
|
|
plan = _StubPlan()
|
|
clips = [
|
|
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0, transition="fade"),
|
|
_make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0, transition="cut"),
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
self.assertEqual(len(cmd.input_paths), 2)
|
|
self.assertIn("xfade", cmd.filter_complex)
|
|
self.assertIn("transition=fade", cmd.filter_complex)
|
|
# 总时长应减去转场时长
|
|
self.assertLess(cmd.estimated_duration, 13.0)
|
|
|
|
def test_custom_output_params(self):
|
|
"""自定义输出参数。"""
|
|
plan = _StubPlan()
|
|
clips = [_make_ready_clip(plan_id=plan.id)]
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_compose_command(
|
|
plan.id,
|
|
"/tmp/out.mp4",
|
|
output_width=1920,
|
|
output_height=1080,
|
|
codec="libx265",
|
|
crf=28,
|
|
)
|
|
|
|
self.assertIn("-crf", cmd.command)
|
|
crf_idx = cmd.command.index("-crf")
|
|
self.assertEqual(cmd.command[crf_idx + 1], "28")
|
|
|
|
def test_filter_chain_contains_scale_and_crop(self):
|
|
"""滤镜链包含 scale 和 crop。"""
|
|
plan = _StubPlan()
|
|
clips = [_make_ready_clip(plan_id=plan.id)]
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
chain = cmd.clip_chains[0]
|
|
filter_text = ",".join(chain.filters)
|
|
self.assertIn("scale=", filter_text)
|
|
self.assertIn("crop=", filter_text)
|
|
self.assertIn("trim=", filter_text)
|
|
|
|
def test_start_time_offset(self):
|
|
"""片段 start_time > 0 时生成 setpts 偏移。"""
|
|
plan = _StubPlan()
|
|
clips = [_make_ready_clip(plan_id=plan.id, duration=5.0)]
|
|
clips[0].start_time = 2.5
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
|
|
|
|
chain = cmd.clip_chains[0]
|
|
filter_text = ",".join(chain.filters)
|
|
self.assertIn("2.5/TB", filter_text)
|
|
|
|
|
|
class TestBuildSingleClipCommand(TestCase):
|
|
"""build_single_clip_command 测试。"""
|
|
|
|
def test_clip_not_found_raises(self):
|
|
"""片段不存在 → ValueError。"""
|
|
svc = _make_service()
|
|
with self.assertRaises(ValueError):
|
|
svc.build_single_clip_command("nonexistent", "/tmp/out.mp4")
|
|
|
|
def test_no_asset_raises(self):
|
|
"""片段没有素材 → ValueError。"""
|
|
plan = _StubPlan()
|
|
clips = [_StubClip(clip_id="c1", plan_id=plan.id, asset_id="")]
|
|
svc = _make_service(plan, clips)
|
|
with self.assertRaises(ValueError):
|
|
svc.build_single_clip_command("c1", "/tmp/out.mp4")
|
|
|
|
def test_single_clip_preview(self):
|
|
"""单片段预览命令。"""
|
|
plan = _StubPlan()
|
|
clips = [_make_ready_clip(clip_id="c1", plan_id=plan.id)]
|
|
svc = _make_service(plan, clips)
|
|
cmd = svc.build_single_clip_command("c1", "/tmp/preview.mp4")
|
|
|
|
self.assertEqual(cmd.output_path, "/tmp/preview.mp4")
|
|
self.assertEqual(len(cmd.clip_chains), 1)
|
|
# filter_complex 字段是原始滤镜字符串(不含 [outv] 标签)
|
|
self.assertIn("scale=", cmd.filter_complex)
|
|
# 完整命令中包含 [outv]
|
|
self.assertIn("[outv]", " ".join(cmd.command))
|
|
|
|
|
|
class TestGetComposeStatus(TestCase):
|
|
"""get_compose_status 测试。"""
|
|
|
|
def test_plan_not_found_raises(self):
|
|
"""计划不存在 → ValueError。"""
|
|
svc = _make_service()
|
|
with self.assertRaises(ValueError):
|
|
svc.get_compose_status("nonexistent")
|
|
|
|
def test_status_summary(self):
|
|
"""状态摘要正确。"""
|
|
plan = _StubPlan(status=EditPlanStatus.EDITING)
|
|
clips = [
|
|
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0),
|
|
_StubClip(
|
|
clip_id="c2",
|
|
plan_id=plan.id,
|
|
order=1,
|
|
status=EditPlanClipStatus.PENDING,
|
|
asset_id="b.mp4",
|
|
),
|
|
_StubClip(
|
|
clip_id="c3",
|
|
plan_id=plan.id,
|
|
order=2,
|
|
status=EditPlanClipStatus.RENDERED,
|
|
asset_id="c.mp4",
|
|
duration=3.0,
|
|
),
|
|
]
|
|
svc = _make_service(plan, clips)
|
|
status = svc.get_compose_status(plan.id)
|
|
|
|
self.assertEqual(status["plan_id"], plan.id)
|
|
self.assertEqual(status["plan_status"], "editing")
|
|
self.assertEqual(status["total_clips"], 3)
|
|
self.assertEqual(status["ready_clips"], 1)
|
|
self.assertEqual(status["pending_clips"], 1)
|
|
self.assertEqual(status["rendered_clips"], 1)
|
|
self.assertEqual(status["total_duration"], 13.0) # 5.0 + 5.0 + 3.0(所有有 duration 的片段)
|
|
self.assertTrue(status["can_compose"])
|
|
|
|
|
|
class TestChainFilters(TestCase):
|
|
"""_chain_filters 辅助函数测试。"""
|
|
|
|
def test_basic_chain(self):
|
|
"""基本滤镜链。"""
|
|
result = _chain_filters(["scale=1280:720", "crop=1280:720"], "v0")
|
|
self.assertEqual(result, "[0:v]scale=1280:720,crop=1280:720[v0]")
|
|
|
|
def test_empty_filters(self):
|
|
"""空滤镜列表。"""
|
|
result = _chain_filters([], "v0")
|
|
self.assertEqual(result, "[0:v][v0]")
|
|
|
|
|
|
class TestBuildConcatFilter(TestCase):
|
|
"""_build_concat_filter 测试。"""
|
|
|
|
def test_single_clip(self):
|
|
"""单片段 concat。"""
|
|
from app.services.video_compose_service import ClipFilterChain
|
|
|
|
chains = [
|
|
ClipFilterChain(
|
|
clip_id="c1",
|
|
input_index=0,
|
|
video_label="v0",
|
|
audio_label="a0",
|
|
filters=["scale=1280:720", "trim=0:5"],
|
|
duration=5.0,
|
|
)
|
|
]
|
|
filter_str, duration = _build_concat_filter(chains)
|
|
self.assertIn("concat=n=1", filter_str)
|
|
self.assertEqual(duration, 5.0)
|
|
|
|
def test_multi_clip(self):
|
|
"""多片段 concat。"""
|
|
from app.services.video_compose_service import ClipFilterChain
|
|
|
|
chains = [
|
|
ClipFilterChain(
|
|
clip_id="c1",
|
|
input_index=0,
|
|
video_label="v0",
|
|
audio_label=None,
|
|
filters=["scale=1280:720"],
|
|
duration=5.0,
|
|
),
|
|
ClipFilterChain(
|
|
clip_id="c2",
|
|
input_index=1,
|
|
video_label="v1",
|
|
audio_label=None,
|
|
filters=["scale=1280:720"],
|
|
duration=8.0,
|
|
),
|
|
]
|
|
filter_str, duration = _build_concat_filter(chains)
|
|
self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str)
|
|
self.assertEqual(duration, 13.0)
|
|
|
|
|
|
class TestBuildXfadeFilter(TestCase):
|
|
"""_build_xfade_filter 测试。"""
|
|
|
|
def test_two_clips_with_fade(self):
|
|
"""两个片段 + fade 转场。"""
|
|
from app.services.video_compose_service import ClipFilterChain
|
|
|
|
chains = [
|
|
ClipFilterChain(
|
|
clip_id="c1",
|
|
input_index=0,
|
|
video_label="v0",
|
|
audio_label=None,
|
|
filters=["scale=1280:720"],
|
|
duration=5.0,
|
|
),
|
|
ClipFilterChain(
|
|
clip_id="c2",
|
|
input_index=1,
|
|
video_label="v1",
|
|
audio_label=None,
|
|
filters=["scale=1280:720"],
|
|
duration=8.0,
|
|
),
|
|
]
|
|
filter_str, duration = _build_xfade_filter(chains, transition_duration=0.5, transitions=["cut", "fade"])
|
|
self.assertIn("xfade=transition=fade", filter_str)
|
|
self.assertIn("duration=0.5", filter_str)
|
|
self.assertIn("[outv]", filter_str)
|
|
# 总时长 = 5 + 8 - 0.5 = 12.5
|
|
self.assertAlmostEqual(duration, 12.5, places=2)
|
|
|
|
def test_three_clips_chained_xfade(self):
|
|
"""三个片段链式 xfade。"""
|
|
from app.services.video_compose_service import ClipFilterChain
|
|
|
|
chains = [
|
|
ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label=None, filters=[], duration=5.0),
|
|
ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=5.0),
|
|
ClipFilterChain(clip_id="c3", input_index=2, video_label="v2", audio_label=None, filters=[], duration=5.0),
|
|
]
|
|
filter_str, duration = _build_xfade_filter(
|
|
chains, transition_duration=0.5, transitions=["cut", "fade", "slide_left"]
|
|
)
|
|
self.assertIn("xfade=transition=fade", filter_str)
|
|
self.assertIn("xfade=transition=slideleft", filter_str)
|
|
# 总时长 = 15 - 0.5*2 = 14.0
|
|
self.assertAlmostEqual(duration, 14.0, places=2)
|
|
|
|
|
|
class TestHasAudioTitleSubtitleFix(TestCase):
|
|
"""P0 修复验证:title/subtitle 片段不应有音频流。"""
|
|
|
|
def _make_clip(self, clip_id, clip_type, **kwargs):
|
|
"""创建测试用 stub clip。"""
|
|
return _StubClip(clip_id=clip_id, clip_type=clip_type, **kwargs)
|
|
|
|
def test_title_clip_has_no_audio_label(self):
|
|
"""title 类型片段的 audio_label 应为 None。"""
|
|
clip = self._make_clip("c1", "title")
|
|
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
|
|
self.assertIsNone(chain.audio_label, "title 片段不应有音频标签")
|
|
|
|
def test_subtitle_clip_has_no_audio_label(self):
|
|
"""subtitle 类型片段的 audio_label 应为 None。"""
|
|
clip = self._make_clip("c1", "subtitle")
|
|
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
|
|
self.assertIsNone(chain.audio_label, "subtitle 片段不应有音频标签")
|
|
|
|
def test_main_clip_has_audio_label(self):
|
|
"""main 类型片段应有音频标签。"""
|
|
clip = self._make_clip("c1", "main")
|
|
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
|
|
self.assertEqual(chain.audio_label, "a0")
|
|
|
|
def test_intro_clip_has_audio_label(self):
|
|
"""intro 类型片段应有音频标签。"""
|
|
clip = self._make_clip("c1", "intro")
|
|
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
|
|
self.assertEqual(chain.audio_label, "a0")
|
|
|
|
def test_has_audio_false_when_only_title_subtitle(self):
|
|
"""当所有片段都是 title/subtitle 时,_has_audio 应返回 False。"""
|
|
chains = [
|
|
VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25),
|
|
VideoComposeService._build_clip_filter(self._make_clip("c2", "subtitle"), 1, 1280, 720, 25),
|
|
]
|
|
self.assertFalse(VideoComposeService._has_audio(chains))
|
|
|
|
def test_has_audio_true_when_mixed_clips(self):
|
|
"""混合片段(含 main)时,_has_audio 应返回 True。"""
|
|
chains = [
|
|
VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25),
|
|
VideoComposeService._build_clip_filter(self._make_clip("c2", "main"), 1, 1280, 720, 25),
|
|
]
|
|
self.assertTrue(VideoComposeService._has_audio(chains))
|
|
|
|
def test_empty_clip_type_has_audio(self):
|
|
"""clip_type 为空字符串时,应有音频标签(保守策略)。"""
|
|
clip = self._make_clip("c1", "")
|
|
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
|
|
self.assertEqual(chain.audio_label, "a0")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import unittest
|
|
|
|
unittest.main()
|