Files
xiaoxia-saas/tests/unit/test_multi_track_subtitle_concat.py
T
xiaoxia 76fdab4f63
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m2s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m27s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m33s
CI/CD Pipeline / Integration Tests (push) Successful in 2m51s
CI/CD Pipeline / Unit Tests (push) Successful in 10m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 29s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m31s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(ci): 修复develop分支CI核心链路失败(Code Quality + Unit Tests + Frontend) (#1129)
2026-07-29 12:12:37 +08:00

1019 lines
34 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.
"""多轨道混音 + 字幕渲染引擎 + 视频拼接 单元测试.
测试:
1. 多轨道混音:配置解析、轨道预处理、多轨混音、降级
2. 字幕渲染引擎:样式配置、ASS生成、多源字幕合并、滤镜构建
3. 视频拼接:配置解析、stream copy、concat filter、降级
"""
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def work_dir(tmp_path):
return tmp_path
@pytest.fixture
def main_audio_path(work_dir):
"""生成 10 秒测试主音频."""
import subprocess
path = work_dir / "main.aac"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=10:sample_rate=44100",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
],
capture_output=True,
check=True,
timeout=30,
)
return path
@pytest.fixture
def sfx_audio_path(work_dir):
"""生成 3 秒效音频."""
import subprocess
path = work_dir / "sfx.aac"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=880:duration=3:sample_rate=44100",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
],
capture_output=True,
check=True,
timeout=30,
)
return path
@pytest.fixture
def voiceover_audio_path(work_dir):
"""生成 5 秒配音音频."""
import subprocess
path = work_dir / "voiceover.aac"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=220:duration=5:sample_rate=44100",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
],
capture_output=True,
check=True,
timeout=30,
)
return path
@pytest.fixture
def test_video_1(work_dir):
"""生成 5 秒测试视频11080x1920, 30fps."""
import subprocess
path = work_dir / "video1.mp4"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=1080x1920:d=5:r=30",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=5:sample_rate=44100",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
str(path),
],
capture_output=True,
check=True,
timeout=60,
)
return path
@pytest.fixture
def test_video_2(work_dir):
"""生成 5 秒测试视频21080x1920, 30fps."""
import subprocess
path = work_dir / "video2.mp4"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=blue:s=1080x1920:d=5:r=30",
"-f",
"lavfi",
"-i",
"sine=frequency=660:duration=5:sample_rate=44100",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
str(path),
],
capture_output=True,
check=True,
timeout=60,
)
return path
# ============================================================================
# 一、多轨道混音测试
# ============================================================================
class TestAudioTrack:
"""AudioTrack 配置解析测试."""
def test_default_values(self):
from video_processing.multi_track_mixer import AudioTrack
track = AudioTrack.from_dict({"track_id": "t1", "audio_path": "/tmp/test.aac"})
assert track.track_id == "t1"
assert track.audio_path == "/tmp/test.aac"
assert track.volume == pytest.approx(0.7) # sfx 默认音量
assert track.track_type == "sfx"
assert track.enabled is True
assert track.fade_in == 0.0
assert track.start_time == 0.0
def test_volume_clamping(self):
from video_processing.multi_track_mixer import AudioTrack
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/tmp/test.aac",
"volume": 5.0,
}
)
assert track.volume == pytest.approx(2.0) # 上限钳制
track2 = AudioTrack.from_dict(
{
"track_id": "t2",
"audio_path": "/tmp/test.aac",
"volume": -1.0,
}
)
assert track2.volume == pytest.approx(0.0) # 下限钳制
def test_track_type_default_volume(self):
from video_processing.multi_track_mixer import DEFAULT_VOLUMES, AudioTrack
for track_type, expected_vol in DEFAULT_VOLUMES.items():
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": track_type,
"audio_path": "/tmp/test.aac",
}
)
assert track.volume == pytest.approx(expected_vol)
def test_invalid_config_safe(self):
from video_processing.multi_track_mixer import AudioTrack
# 无效值应该安全降级到默认值
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/tmp/test.aac",
"volume": "invalid",
"fade_in": "abc",
"start_time": None,
}
)
assert track.volume > 0 # 有默认值
assert track.fade_in == 0.0
assert track.start_time == 0.0
def test_disabled_track(self):
from video_processing.multi_track_mixer import AudioTrack
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/tmp/test.aac",
"enabled": False,
}
)
assert track.enabled is False
class TestMultiTrackMixConfig:
"""MultiTrackMixConfig 配置解析测试."""
def test_empty_config(self):
from video_processing.multi_track_mixer import MultiTrackMixConfig
config = MultiTrackMixConfig.from_config_dict(None)
assert config.has_effect is False
assert len(config.tracks) == 0
def test_empty_dict(self):
from video_processing.multi_track_mixer import MultiTrackMixConfig
config = MultiTrackMixConfig.from_config_dict({})
assert config.has_effect is False
assert len(config.tracks) == 0
def test_valid_tracks(self):
from video_processing.multi_track_mixer import MultiTrackMixConfig
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "sfx1", "track_type": "sfx", "audio_path": "/tmp/sfx1.aac", "volume": 0.5},
{"track_id": "vo1", "track_type": "voiceover", "audio_path": "/tmp/vo1.aac"},
],
"master_volume": 0.8,
}
)
assert config.has_effect is True
assert len(config.tracks) == 2
assert config.tracks[0].volume == pytest.approx(0.5)
assert config.master_volume == pytest.approx(0.8)
def test_skip_invalid_tracks(self):
from video_processing.multi_track_mixer import MultiTrackMixConfig
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "valid", "audio_path": "/tmp/valid.aac"},
{"track_id": "no_path"}, # 没有 audio_path,应该跳过
"not_a_dict", # 不是字典,应该跳过
{"track_id": "disabled", "audio_path": "/tmp/dis.aac", "enabled": False},
],
}
)
# 只有 valid 一个有效(disabled 的也跳过)
assert len([t for t in config.tracks if t.enabled]) == 1
class TestMultiTrackMix:
"""多轨道混音集成测试."""
def test_mix_two_tracks(self, work_dir, main_audio_path, sfx_audio_path):
"""主音频 + 音效轨混音."""
from video_processing.multi_track_mixer import AudioTrack, MultiTrackMixConfig, mix_multi_track
from video_processing.render_audio import RenderContext
ctx = RenderContext(work_dir=work_dir, plan_id="test")
config = MultiTrackMixConfig(
tracks=[
AudioTrack(
track_id="sfx1",
track_type="sfx",
audio_path=str(sfx_audio_path),
volume=0.5,
start_time=2.0,
),
],
master_volume=1.0,
)
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
assert output.exists()
assert output.stat().st_size > 0
def test_mix_with_voiceover(self, work_dir, main_audio_path, voiceover_audio_path):
"""主音频 + 配音轨混音."""
from video_processing.multi_track_mixer import AudioTrack, MultiTrackMixConfig, mix_multi_track
from video_processing.render_audio import RenderContext
ctx = RenderContext(work_dir=work_dir, plan_id="test2")
config = MultiTrackMixConfig(
tracks=[
AudioTrack(
track_id="vo1",
track_type="voiceover",
audio_path=str(voiceover_audio_path),
volume=1.0,
start_time=1.0,
fade_in=0.5,
fade_out=0.5,
),
],
)
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
assert output.exists()
assert output.stat().st_size > 0
def test_no_tracks_returns_main(self, work_dir, main_audio_path):
"""没有附加轨道时返回主音频副本."""
from video_processing.multi_track_mixer import MultiTrackMixConfig, mix_multi_track
from video_processing.render_audio import RenderContext
ctx = RenderContext(work_dir=work_dir, plan_id="test3")
config = MultiTrackMixConfig(tracks=[])
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
assert output.exists()
assert output.stat().st_size > 0
def test_mix_with_fade(self, work_dir, main_audio_path, sfx_audio_path):
"""带淡入淡出的混音."""
from video_processing.multi_track_mixer import AudioTrack, MultiTrackMixConfig, mix_multi_track
from video_processing.render_audio import RenderContext
ctx = RenderContext(work_dir=work_dir, plan_id="test_fade")
config = MultiTrackMixConfig(
tracks=[
AudioTrack(
track_id="sfx_fade",
track_type="sfx",
audio_path=str(sfx_audio_path),
fade_in=0.3,
fade_out=0.3,
start_time=1.0,
),
],
)
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
assert output.exists()
assert output.stat().st_size > 0
def test_mix_audio_tracks_from_config(self, work_dir, main_audio_path, sfx_audio_path):
"""从配置字典混音的便捷函数."""
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
from video_processing.render_audio import RenderContext
ctx = RenderContext(work_dir=work_dir, plan_id="test_config")
config_dict = {
"tracks": [
{
"track_id": "sfx1",
"track_type": "sfx",
"audio_path": str(sfx_audio_path),
"volume": 0.6,
"start_time": 1.0,
},
],
"enabled": True,
}
output = mix_audio_tracks_from_config(ctx, main_audio_path, config_dict, 10.0)
assert output.exists()
assert output.stat().st_size > 0
def test_disabled_config_returns_main(self, work_dir, main_audio_path):
"""配置未启用时返回主音频."""
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
from video_processing.render_audio import RenderContext
ctx = RenderContext(work_dir=work_dir, plan_id="test_disabled")
output = mix_audio_tracks_from_config(ctx, main_audio_path, None, 10.0)
assert output == main_audio_path # 直接返回原文件
# ============================================================================
# 二、字幕渲染引擎测试
# ============================================================================
class TestSubtitleStyle:
"""SubtitleStyle 配置解析测试."""
def test_default_style(self):
from video_processing.subtitle_render_engine import SubtitleStyle
style = SubtitleStyle.from_dict({})
assert style.font_size == 24
assert style.position == "bottom_center"
assert style.stroke_enabled is True
assert style.background_enabled is False
assert style.alignment == 2 # bottom_center → ASS alignment 2
def test_position_aliases(self):
from video_processing.subtitle_render_engine import SubtitleStyle
style = SubtitleStyle.from_dict({"position": "top"})
assert style.position == "top_center"
assert style.alignment == 8
style2 = SubtitleStyle.from_dict({"position": "bottom"})
assert style2.position == "bottom_center"
assert style2.alignment == 2
style3 = SubtitleStyle.from_dict({"position": "center"})
assert style3.position == "center"
assert style3.alignment == 5
def test_9grid_positions(self):
from video_processing.subtitle_render_engine import SubtitleStyle
from packages.domain.subtitle_style import POSITION_ALIGNMENT
for pos, align in POSITION_ALIGNMENT.items():
style = SubtitleStyle.from_dict({"position": pos})
assert style.position == pos
assert style.alignment == align
def test_invalid_position_fallback(self):
from video_processing.subtitle_render_engine import SubtitleStyle
style = SubtitleStyle.from_dict({"position": "invalid_position"})
assert style.position == "bottom_center" # 降级到默认
def test_background_style(self):
from video_processing.subtitle_render_engine import SubtitleStyle
style = SubtitleStyle.from_dict(
{
"background_enabled": True,
"background_color": "#000000",
"background_opacity": 0.7,
}
)
assert style.background_enabled is True
assert style.background_opacity == pytest.approx(0.7)
def test_color_conversion(self):
from video_processing.subtitle_render_engine import SubtitleStyle
style = SubtitleStyle.from_dict({"color": "#FF0000"})
# #FF0000 → &H000000FF (ASS 格式: &HAABBGGRR)
assert "FF" in style.ass_font_color
assert "0000" in style.ass_font_color # BB 和 GG 都是 00
def test_safe_type_conversion(self):
from video_processing.subtitle_render_engine import SubtitleStyle
style = SubtitleStyle.from_dict(
{
"size": "invalid",
"margin_v": None,
"bold": "true", # 字符串真值
}
)
assert style.font_size == 24 # 降级到默认
assert style.margin_v == 60
# bool("true") = True,但这是 Python 行为,可以接受
class TestSubtitleRenderEngine:
"""字幕渲染引擎测试."""
def test_empty_engine(self, work_dir):
"""空引擎生成空文件."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
assert engine.has_subtitles is False
output = work_dir / "empty.ass"
engine.generate_ass(output)
assert output.exists()
assert output.read_text(encoding="utf-8") == ""
def test_add_title(self, work_dir):
"""添加标题字幕."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
engine.add_title("测试标题")
assert engine.has_subtitles is True
output = work_dir / "title.ass"
result = engine.generate_ass(output)
assert result.exists()
content = result.read_text(encoding="utf-8")
assert "测试标题" in content
assert "TitleStyle" in content
def test_add_subtitle_text(self, work_dir):
"""添加整段字幕."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
engine.add_subtitle_text("这是一段字幕")
assert engine.has_subtitles is True
output = work_dir / "subtitle.ass"
engine.generate_ass(output)
content = output.read_text(encoding="utf-8")
assert "这是一段字幕" in content
assert "SubtitleStyle" in content
def test_add_timeline_segments(self, work_dir):
"""添加时间轴字幕片段."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
segments = [
{"start": 0.0, "end": 2.0, "text": "第一段字幕"},
{"start": 2.0, "end": 5.0, "text": "第二段字幕"},
{"start": 5.0, "end": 10.0, "text": "第三段字幕"},
]
engine.add_timeline_segments(segments)
assert engine.has_subtitles is True
output = work_dir / "timeline.ass"
engine.generate_ass(output)
content = output.read_text(encoding="utf-8")
assert "第一段字幕" in content
assert "第二段字幕" in content
assert "第三段字幕" in content
# 检查有 3 条 Dialogue 事件
assert content.count("Dialogue:") == 3
def test_mixed_sources(self, work_dir):
"""标题 + 时间轴字幕 混合."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
engine.add_title("视频标题")
engine.add_timeline_segments(
[
{"start": 0.0, "end": 3.0, "text": "ASR 结果1"},
{"start": 3.0, "end": 7.0, "text": "ASR 结果2"},
]
)
output = work_dir / "mixed.ass"
engine.generate_ass(output)
content = output.read_text(encoding="utf-8")
assert "视频标题" in content
assert "ASR 结果1" in content
assert "ASR 结果2" in content
assert content.count("Dialogue:") == 3
def test_fade_animation(self, work_dir):
"""淡入淡出动画效果."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
style = SubtitleStyle(fade_in=0.5, fade_out=0.5)
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
engine.add_timeline_segments(
[{"start": 0.0, "end": 5.0, "text": "淡入淡出测试"}],
style=style,
)
output = work_dir / "fade.ass"
engine.generate_ass(output)
content = output.read_text(encoding="utf-8")
assert "\\fad" in content # ASS 淡入淡出标签
def test_text_wrapping(self, work_dir):
"""长文本自动换行."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
style = SubtitleStyle(max_chars_per_line=10)
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
engine.add_subtitle_text("这是一段非常长的字幕文本,应该会自动换行显示", style=style)
output = work_dir / "wrap.ass"
engine.generate_ass(output)
content = output.read_text(encoding="utf-8")
assert "\\N" in content # ASS 换行符
def test_escape_special_chars(self, work_dir):
"""ASS 特殊字符转义."""
from video_processing.subtitle_render_engine import SubtitleRenderEngine
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
engine.add_subtitle_text("测试{大括号}换行\n第二行")
output = work_dir / "escape.ass"
engine.generate_ass(output)
content = output.read_text(encoding="utf-8")
# 大括号应该被转义
assert "{" not in content.split("Dialogue:")[1].split("测试")[1][:10] or "(" in content
assert "\\N" in content # 换行转义
class TestBuildSubtitlesFromPlan:
"""从 plan.config 构建字幕测试."""
def test_empty_config(self, work_dir):
from video_processing.subtitle_render_engine import build_subtitles_from_plan
output = work_dir / "empty_plan.ass"
result = build_subtitles_from_plan(
output,
{},
video_width=1080,
video_height=1920,
video_duration=10.0,
)
assert result is None
def test_title_only(self, work_dir):
from video_processing.subtitle_render_engine import build_subtitles_from_plan
output = work_dir / "title_plan.ass"
config = {
"title_config": {
"enabled": True,
"text": "我的视频标题",
"style": {"size": 48, "bold": True, "position": "top"},
}
}
result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0)
assert result is not None
assert result.exists()
content = result.read_text(encoding="utf-8")
assert "我的视频标题" in content
def test_manual_subtitles(self, work_dir):
from video_processing.subtitle_render_engine import build_subtitles_from_plan
output = work_dir / "manual.ass"
config = {
"manual_subtitles": [
{"start": 0.0, "end": 2.0, "text": "手动字幕1"},
{"start": 2.5, "end": 5.0, "text": "手动字幕2"},
],
"manual_subtitle_style": {"size": 28, "color": "#FFFF00"},
}
result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0)
assert result is not None
content = result.read_text(encoding="utf-8")
assert "手动字幕1" in content
assert "手动字幕2" in content
assert content.count("Dialogue:") == 2
def test_disabled_title_skipped(self, work_dir):
from video_processing.subtitle_render_engine import build_subtitles_from_plan
output = work_dir / "disabled.ass"
config = {
"title_config": {
"enabled": False,
"text": "不显示的标题",
}
}
result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0)
assert result is None
class TestSubtitleFilter:
"""字幕滤镜构建测试."""
def test_build_subtitle_filter(self, work_dir):
from video_processing.subtitle_render_engine import build_subtitle_filter
ass_file = work_dir / "test.ass"
ass_file.write_text("test", encoding="utf-8")
result = build_subtitle_filter(
str(ass_file),
video_input_label="[v_in]",
output_label="out",
work_dir=work_dir,
)
assert "subtitles=" in result
assert "[v_in]" in result
assert "[out]" in result
def test_default_labels(self, work_dir):
from video_processing.subtitle_render_engine import build_subtitle_filter
ass_file = work_dir / "sub.ass"
ass_file.write_text("test", encoding="utf-8")
result = build_subtitle_filter(str(ass_file), work_dir=work_dir)
assert "0:v" in result
assert "[subtitled]" in result
# ============================================================================
# 三、视频拼接引擎测试
# ============================================================================
class TestConcatSegment:
"""ConcatSegment 配置解析测试."""
def test_default_values(self):
from video_processing.concat_engine import ConcatSegment
seg = ConcatSegment.from_dict({"video_path": "/tmp/test.mp4"})
assert seg.video_path == "/tmp/test.mp4"
assert seg.start_time == 0.0
assert seg.duration == 0.0
assert seg.has_audio is True
def test_trimming_config(self):
from video_processing.concat_engine import ConcatSegment
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/test.mp4",
"start_time": 5.0,
"duration": 10.0,
}
)
assert seg.start_time == 5.0
assert seg.duration == 10.0
def test_invalid_values_safe(self):
from video_processing.concat_engine import ConcatSegment
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/test.mp4",
"start_time": "invalid",
"duration": -5.0,
}
)
assert seg.start_time == 0.0
assert seg.duration == 0.0
class TestConcatConfig:
"""ConcatConfig 配置解析测试."""
def test_empty_config(self):
from video_processing.concat_engine import ConcatConfig
config = ConcatConfig.from_config_dict(None)
assert config.has_effect is False
assert config.total_segments == 0
def test_single_segment_no_effect(self):
from video_processing.concat_engine import ConcatConfig
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/tmp/1.mp4"}],
}
)
assert config.has_effect is False # 只有一段不需要拼接
def test_multiple_segments(self):
from video_processing.concat_engine import ConcatConfig
config = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/tmp/1.mp4"},
{"video_path": "/tmp/2.mp4"},
{"video_path": "/tmp/3.mp4"},
],
"output_width": 1080,
"output_height": 1920,
"output_fps": 30.0,
}
)
assert config.has_effect is True
assert config.total_segments == 3
assert config.output_width == 1080
def test_skip_invalid_segments(self):
from video_processing.concat_engine import ConcatConfig
config = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/tmp/1.mp4"},
{}, # 没有 path
{"video_path": ""}, # 空 path
{"video_path": "/tmp/2.mp4"},
],
}
)
assert config.total_segments == 2
assert config.has_effect is True
class TestConcatEngine:
"""视频拼接引擎集成测试."""
def test_concat_demuxer_stream_copy(self, work_dir, test_video_1, test_video_2):
"""concat demuxer 模式(stream copy."""
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
engine = ConcatEngine(work_dir=work_dir)
config = ConcatConfig(
segments=[
ConcatSegment(video_path=str(test_video_1)),
ConcatSegment(video_path=str(test_video_2)),
],
)
output = work_dir / "concat_demuxer.mp4"
result = engine.concat_videos(config, output)
assert result.exists()
assert result.stat().st_size > 0
# 验证时长大约是两段之和(5+5=10秒)
from video_processing.ffmpeg_utils import probe_duration
dur = probe_duration(str(result))
assert dur > 8.0 # 留一些误差余量
assert dur < 12.0
def test_concat_filter_reencode(self, work_dir, test_video_1, test_video_2):
"""concat filter 模式(强制重新编码)."""
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
engine = ConcatEngine(work_dir=work_dir)
config = ConcatConfig(
segments=[
ConcatSegment(video_path=str(test_video_1)),
ConcatSegment(video_path=str(test_video_2)),
],
force_reencode=True,
)
output = work_dir / "concat_filter.mp4"
result = engine.concat_videos(config, output)
assert result.exists()
assert result.stat().st_size > 0
from video_processing.ffmpeg_utils import probe_duration
dur = probe_duration(str(result))
assert dur > 8.0
assert dur < 12.0
def test_concat_with_trimming(self, work_dir, test_video_1, test_video_2):
"""带裁剪的拼接(自动用 filter 模式)."""
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
engine = ConcatEngine(work_dir=work_dir)
config = ConcatConfig(
segments=[
ConcatSegment(video_path=str(test_video_1), start_time=1.0, duration=2.0),
ConcatSegment(video_path=str(test_video_2), start_time=0.0, duration=3.0),
],
)
output = work_dir / "concat_trimmed.mp4"
result = engine.concat_videos(config, output)
assert result.exists()
assert result.stat().st_size > 0
from video_processing.ffmpeg_utils import probe_duration
dur = probe_duration(str(result))
assert dur > 3.0 # 2+3=5秒
assert dur < 7.0
def test_single_segment_copy(self, work_dir, test_video_1):
"""单片段直接复制."""
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
engine = ConcatEngine(work_dir=work_dir)
config = ConcatConfig(
segments=[ConcatSegment(video_path=str(test_video_1))],
)
output = work_dir / "single.mp4"
result = engine.concat_videos(config, output)
assert result.exists()
def test_concat_video_files_helper(self, work_dir, test_video_1, test_video_2):
"""便捷函数 concat_video_files."""
from video_processing.concat_engine import concat_video_files
output = work_dir / "concat_helper.mp4"
result = concat_video_files(
[str(test_video_1), str(test_video_2)],
output,
work_dir=work_dir,
)
assert result.exists()
assert result.stat().st_size > 0
def test_concat_from_config(self, work_dir, test_video_1, test_video_2):
"""从配置字典拼接的便捷函数."""
from video_processing.concat_engine import concat_videos_from_config
output = work_dir / "concat_config.mp4"
config_dict = {
"segments": [
{"video_path": str(test_video_1)},
{"video_path": str(test_video_2)},
],
}
result = concat_videos_from_config(config_dict, output, work_dir=work_dir)
assert result is not None
assert result.exists()
def test_concat_empty_config_returns_none(self, work_dir):
"""空配置返回 None."""
from video_processing.concat_engine import concat_videos_from_config
output = work_dir / "empty.mp4"
result = concat_videos_from_config(None, output, work_dir=work_dir)
assert result is None
def test_three_videos_concat(self, work_dir, test_video_1, test_video_2):
"""三段视频拼接."""
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
engine = ConcatEngine(work_dir=work_dir)
config = ConcatConfig(
segments=[
ConcatSegment(video_path=str(test_video_1)),
ConcatSegment(video_path=str(test_video_2)),
ConcatSegment(video_path=str(test_video_1)),
],
force_reencode=True,
)
output = work_dir / "three_videos.mp4"
result = engine.concat_videos(config, output)
assert result.exists()
from video_processing.ffmpeg_utils import probe_duration
dur = probe_duration(str(result))
assert dur > 12.0 # 5+5+5=15秒
assert dur < 18.0
def test_output_resolution_override(self, work_dir, test_video_1, test_video_2):
"""指定输出分辨率."""
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
engine = ConcatEngine(work_dir=work_dir)
config = ConcatConfig(
segments=[
ConcatSegment(video_path=str(test_video_1)),
ConcatSegment(video_path=str(test_video_2)),
],
output_width=720,
output_height=1280,
force_reencode=True,
)
output = work_dir / "concat_720p.mp4"
result = engine.concat_videos(config, output)
assert result.exists()
from video_processing.ffmpeg_utils import probe_video_info
info = probe_video_info(str(result))
assert int(info.get("width", 0)) == 720
assert int(info.get("height", 0)) == 1280