7378ef378e
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m17s
CI/CD Pipeline / Unit Tests (push) Successful in 3m33s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m14s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 20s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m34s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m17s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 2m55s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
719 lines
27 KiB
Python
Executable File
719 lines
27 KiB
Python
Executable File
"""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_pad(self):
|
||
"""滤镜链包含 scale 和 pad(保持比例留黑边,不裁剪)。"""
|
||
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("force_original_aspect_ratio=decrease", filter_text)
|
||
self.assertIn("pad=", filter_text)
|
||
self.assertIn("black", filter_text)
|
||
self.assertIn("trim=", filter_text)
|
||
|
||
def test_filter_chain_contains_format_yuv420p(self):
|
||
"""滤镜链包含 format=yuv420p,统一像素格式避免 concat 失败。"""
|
||
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)
|
||
# format=yuv420p 必须在 pad 之后、fps 之后(像素格式统一放在分辨率之后)
|
||
pad_idx = filter_text.index("pad=")
|
||
fmt_idx = filter_text.index("format=yuv420p")
|
||
self.assertGreater(fmt_idx, pad_idx, "format 应该在 pad 之后")
|
||
|
||
def test_filter_chain_contains_fps(self):
|
||
"""滤镜链包含 fps 滤镜,用于统一帧率避免 concat 失败。"""
|
||
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", fps=25)
|
||
|
||
chain = cmd.clip_chains[0]
|
||
filter_text = ",".join(chain.filters)
|
||
# fps 必须在 pad 之后、setpts 之前
|
||
pad_idx = filter_text.index("pad=")
|
||
fps_idx = filter_text.index("fps=25")
|
||
setpts_idx = filter_text.index("setpts=")
|
||
self.assertGreater(fps_idx, pad_idx, "fps 应该在 pad 之后")
|
||
self.assertLess(fps_idx, setpts_idx, "fps 应该在 setpts 之前")
|
||
|
||
def test_fps_zero_or_none_skips_fps_filter(self):
|
||
"""fps 为 0 或负值时不添加 fps 滤镜。"""
|
||
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", fps=0)
|
||
|
||
chain = cmd.clip_chains[0]
|
||
filter_text = ",".join(chain.filters)
|
||
self.assertNotIn("fps=", 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)
|
||
|
||
def test_multi_clip_with_audio_aformat(self):
|
||
"""多片段+音频时,每个音频流都经过 aformat 归一化再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"],
|
||
duration=5.0,
|
||
),
|
||
ClipFilterChain(
|
||
clip_id="c2",
|
||
input_index=1,
|
||
video_label="v1",
|
||
audio_label="a1",
|
||
filters=["scale=1280:720"],
|
||
duration=8.0,
|
||
),
|
||
]
|
||
filter_str, _ = _build_concat_filter(chains)
|
||
# 两个音频流都必须有 aformat 归一化(48000Hz + stereo + fltp)
|
||
self.assertIn("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", filter_str)
|
||
# 音频 concat
|
||
self.assertIn("concat=n=2:v=0:a=1[outa]", filter_str)
|
||
# aformat 在 atrim 之前(先统一格式再裁剪)
|
||
aformat_idx = filter_str.index("aformat=")
|
||
atrim_idx = filter_str.index("atrim=")
|
||
self.assertLess(aformat_idx, atrim_idx, "aformat 应该在 atrim 之前")
|
||
|
||
|
||
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)
|
||
|
||
def test_xfade_with_audio_aformat_normalization(self):
|
||
"""xfade 路径下多片段音频 concat 前必须经过 aformat 归一化。"""
|
||
from app.services.video_compose_service import ClipFilterChain
|
||
|
||
chains = [
|
||
ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=[], duration=5.0),
|
||
ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label="a1", filters=[], duration=8.0),
|
||
]
|
||
filter_str, _ = _build_xfade_filter(chains, transition_duration=0.5, transitions=["fade"])
|
||
# 两个音频流都必须经过 aformat 归一化(48000Hz + stereo + fltp)
|
||
aformat_count = filter_str.count("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
|
||
self.assertEqual(aformat_count, 2, "两个音频片段都应该有aformat归一化")
|
||
# 最终音频 concat
|
||
self.assertIn("concat=n=2:v=0:a=1[outa]", filter_str)
|
||
|
||
def test_xfade_single_audio_passthrough(self):
|
||
"""xfade 路径下只有一个音频片段时直接 acopy。"""
|
||
from app.services.video_compose_service import ClipFilterChain
|
||
|
||
chains = [
|
||
ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=[], duration=5.0),
|
||
ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=8.0),
|
||
]
|
||
filter_str, _ = _build_xfade_filter(chains, transition_duration=0.5, transitions=["fade"])
|
||
self.assertIn("[a0]acopy[outa]", filter_str)
|
||
|
||
|
||
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()
|