Files
xiaoxia-saas/tests/unit/test_video_compose_service.py
T
xiaoxia 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
fix(P0): legacy渲染引擎5项normalize补全 + ffmpeg错误日志增强 (#511)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 15:07:58 +08:00

719 lines
27 KiB
Python
Executable File
Raw 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.
"""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()