"""多轨道混音 + 字幕渲染引擎 + 视频拼接 单元测试. 测试: 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 秒测试视频1(1080x1920, 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 秒测试视频2(1080x1920, 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 POSITION_ALIGNMENT, SubtitleStyle 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