diff --git a/tests/unit/test_multi_track_subtitle_concat.py b/tests/unit/test_multi_track_subtitle_concat.py index 9c65e8e35..3d80e565d 100755 --- a/tests/unit/test_multi_track_subtitle_concat.py +++ b/tests/unit/test_multi_track_subtitle_concat.py @@ -31,11 +31,21 @@ def main_audio_path(work_dir): 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), + "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, + capture_output=True, + check=True, + timeout=30, ) return path @@ -48,11 +58,21 @@ def sfx_audio_path(work_dir): 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), + "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, + capture_output=True, + check=True, + timeout=30, ) return path @@ -65,11 +85,21 @@ def voiceover_audio_path(work_dir): 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), + "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, + capture_output=True, + check=True, + timeout=30, ) return path @@ -82,15 +112,30 @@ def test_video_1(work_dir): 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), + "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, + capture_output=True, + check=True, + timeout=60, ) return path @@ -103,15 +148,30 @@ def test_video_2(work_dir): 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), + "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, + capture_output=True, + check=True, + timeout=60, ) return path @@ -139,42 +199,50 @@ class TestAudioTrack: 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, - }) + 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, - }) + 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 AudioTrack, DEFAULT_VOLUMES + 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", - }) + 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, - }) + 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 @@ -182,11 +250,13 @@ class TestAudioTrack: 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, - }) + track = AudioTrack.from_dict( + { + "track_id": "t1", + "audio_path": "/tmp/test.aac", + "enabled": False, + } + ) assert track.enabled is False @@ -210,13 +280,15 @@ class TestMultiTrackMixConfig: 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, - }) + 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) @@ -225,14 +297,16 @@ class TestMultiTrackMixConfig: 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}, - ], - }) + 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 @@ -394,7 +468,7 @@ class TestSubtitleStyle: assert style3.alignment == 5 def test_9grid_positions(self): - from video_processing.subtitle_render_engine import SubtitleStyle, POSITION_ALIGNMENT + from video_processing.subtitle_render_engine import POSITION_ALIGNMENT, SubtitleStyle for pos, align in POSITION_ALIGNMENT.items(): style = SubtitleStyle.from_dict({"position": pos}) @@ -410,11 +484,13 @@ class TestSubtitleStyle: 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, - }) + 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) @@ -429,11 +505,13 @@ class TestSubtitleStyle: 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", # 字符串真值 - }) + 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 行为,可以接受 @@ -511,10 +589,12 @@ class TestSubtitleRenderEngine: 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"}, - ]) + 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) @@ -595,9 +675,7 @@ class TestBuildSubtitlesFromPlan: "style": {"size": 48, "bold": True, "position": "top"}, } } - result = build_subtitles_from_plan( - output, config, video_width=1080, video_height=1920, video_duration=10.0 - ) + 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") @@ -614,9 +692,7 @@ class TestBuildSubtitlesFromPlan: ], "manual_subtitle_style": {"size": 28, "color": "#FFFF00"}, } - result = build_subtitles_from_plan( - output, config, video_width=1080, video_height=1920, video_duration=10.0 - ) + 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 @@ -633,9 +709,7 @@ class TestBuildSubtitlesFromPlan: "text": "不显示的标题", } } - result = build_subtitles_from_plan( - output, config, video_width=1080, video_height=1920, video_duration=10.0 - ) + result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0) assert result is None @@ -678,22 +752,26 @@ class TestConcatSegment: 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, - }) + 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, - }) + 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 @@ -711,24 +789,28 @@ class TestConcatConfig: 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"}], - }) + 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, - }) + 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 @@ -736,14 +818,16 @@ class TestConcatConfig: 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"}, - ], - }) + 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 @@ -753,7 +837,7 @@ 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 ConcatEngine, ConcatConfig, ConcatSegment + from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment engine = ConcatEngine(work_dir=work_dir) config = ConcatConfig( @@ -777,7 +861,7 @@ class TestConcatEngine: def test_concat_filter_reencode(self, work_dir, test_video_1, test_video_2): """concat filter 模式(强制重新编码).""" - from video_processing.concat_engine import ConcatEngine, ConcatConfig, ConcatSegment + from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment engine = ConcatEngine(work_dir=work_dir) config = ConcatConfig( @@ -801,7 +885,7 @@ class TestConcatEngine: def test_concat_with_trimming(self, work_dir, test_video_1, test_video_2): """带裁剪的拼接(自动用 filter 模式).""" - from video_processing.concat_engine import ConcatEngine, ConcatConfig, ConcatSegment + from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment engine = ConcatEngine(work_dir=work_dir) config = ConcatConfig( @@ -824,7 +908,7 @@ class TestConcatEngine: def test_single_segment_copy(self, work_dir, test_video_1): """单片段直接复制.""" - from video_processing.concat_engine import ConcatEngine, ConcatConfig, ConcatSegment + from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment engine = ConcatEngine(work_dir=work_dir) config = ConcatConfig( @@ -859,9 +943,7 @@ class TestConcatEngine: {"video_path": str(test_video_2)}, ], } - result = concat_videos_from_config( - config_dict, output, work_dir=work_dir - ) + result = concat_videos_from_config(config_dict, output, work_dir=work_dir) assert result is not None assert result.exists() @@ -875,7 +957,7 @@ class TestConcatEngine: def test_three_videos_concat(self, work_dir, test_video_1, test_video_2): """三段视频拼接.""" - from video_processing.concat_engine import ConcatEngine, ConcatConfig, ConcatSegment + from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment engine = ConcatEngine(work_dir=work_dir) config = ConcatConfig( @@ -899,7 +981,7 @@ class TestConcatEngine: def test_output_resolution_override(self, work_dir, test_video_1, test_video_2): """指定输出分辨率.""" - from video_processing.concat_engine import ConcatEngine, ConcatConfig, ConcatSegment + from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment engine = ConcatEngine(work_dir=work_dir) config = ConcatConfig(