| 1 | |
| 2 | import unittest |
| 3 | import os |
| 4 | import shutil |
| 5 | import sys |
| 6 | import tempfile |
| 7 | import types |
| 8 | from contextlib import redirect_stdout |
| 9 | from io import StringIO |
| 10 | from pathlib import Path |
| 11 | from unittest.mock import patch |
| 12 | from moviepy import ( |
| 13 | VideoFileClip, |
| 14 | ) |
| 15 | # add project root to python path |
| 16 | sys.path.insert(0, str(Path(__file__).parent.parent.parent)) |
| 17 | from app.config import config |
| 18 | from app.controllers.manager.base_manager import TaskQueueFullError |
| 19 | from app.controllers.manager.memory_manager import InMemoryTaskManager |
| 20 | from app.controllers.v1 import video as video_controller |
| 21 | from app.models import const |
| 22 | from app.models.schema import MaterialInfo |
| 23 | from app.services import state as sm |
| 24 | from app.services import video as vd |
| 25 | from app.utils import utils |
| 26 | |
| 27 | resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources") |
| 28 | |
| 29 | |
| 30 | class _FakeRequest: |
| 31 | def __init__(self): |
| 32 | self.headers = {"x-task-id": "test-request"} |
| 33 | |
| 34 | |
| 35 | class TestSecurityControls(unittest.TestCase): |
| 36 | def setUp(self): |
| 37 | self.original_app_config = dict(config.app) |
| 38 | |
| 39 | def tearDown(self): |
| 40 | config.app.clear() |
| 41 | config.app.update(self.original_app_config) |
| 42 | |
| 43 | def test_task_query_returns_relative_task_url_without_mutating_state(self): |
| 44 | """ |
| 45 | endpoint 未显式配置时,任务查询接口不能使用 Host 派生绝对 URL, |
| 46 | 也不能把展示 URL 回写到任务状态里,否则不同 Host 查询会污染结果。 |
| 47 | """ |
| 48 | task_id = "security-task-url" |
| 49 | task_dir = utils.task_dir(task_id) |
| 50 | video_path = os.path.join(task_dir, "final-1.mp4") |
| 51 | Path(video_path).write_bytes(b"fake-video") |
| 52 | config.app["endpoint"] = "" |
| 53 | |
| 54 | try: |
| 55 | sm.state.update_task( |
| 56 | task_id, |
| 57 | state=const.TASK_STATE_COMPLETE, |
| 58 | videos=[video_path], |
| 59 | combined_videos=[video_path], |
| 60 | ) |
| 61 | |
| 62 | response = video_controller.get_task(_FakeRequest(), task_id=task_id) |
| 63 | |
| 64 | self.assertEqual(response["data"]["videos"], [f"/tasks/{task_id}/final-1.mp4"]) |
| 65 | self.assertEqual(sm.state.get_task(task_id)["videos"], [video_path]) |
| 66 | finally: |
| 67 | sm.state.delete_task(task_id) |
| 68 | shutil.rmtree(task_dir, ignore_errors=True) |
| 69 | |
| 70 | def test_in_memory_task_manager_rejects_when_queue_is_full(self): |
| 71 | """ |
| 72 | 并发数用尽后,等待队列必须有硬上限。这里用 max_concurrent_tasks=0 |
| 73 | 强制任务进入队列,验证超过 max_queued_tasks 时会拒绝继续入队。 |
| 74 | """ |
| 75 | manager = InMemoryTaskManager(max_concurrent_tasks=0, max_queued_tasks=1) |
| 76 | |
| 77 | manager.add_task(lambda: None) |
| 78 | |
| 79 | with self.assertRaises(TaskQueueFullError): |
| 80 | manager.add_task(lambda: None) |
| 81 | |
| 82 | class TestVideoService(unittest.TestCase): |
| 83 | def setUp(self): |
| 84 | self.original_app_config = dict(config.app) |
| 85 | self.test_img_path = os.path.join(resources_dir, "1.png") |
| 86 | vd._runtime_disabled_video_codecs.clear() |
| 87 | vd._ffmpeg_encoder_exists.cache_clear() |
| 88 | |
| 89 | def tearDown(self): |
| 90 | config.app.clear() |
| 91 | config.app.update(self.original_app_config) |
| 92 | vd._runtime_disabled_video_codecs.clear() |
| 93 | vd._ffmpeg_encoder_exists.cache_clear() |
| 94 | |
| 95 | def test_preprocess_video(self): |
| 96 | if not os.path.exists(self.test_img_path): |
| 97 | self.fail(f"test image not found: {self.test_img_path}") |
| 98 | |
| 99 | local_videos_dir = utils.storage_dir("local_videos", create=True) |
| 100 | safe_img_path = os.path.join(local_videos_dir, "test-preprocess-1.png") |
| 101 | shutil.copy2(self.test_img_path, safe_img_path) |
| 102 | |
| 103 | # test preprocess_video function |
| 104 | m = MaterialInfo() |
| 105 | m.url = os.path.basename(safe_img_path) |
| 106 | m.provider = "local" |
| 107 | print(m) |
| 108 | |
| 109 | try: |
| 110 | materials = vd.preprocess_video([m], clip_duration=4) |
| 111 | print(materials) |
| 112 | |
| 113 | # verify result |
| 114 | self.assertIsNotNone(materials) |
| 115 | self.assertEqual(len(materials), 1) |
| 116 | self.assertTrue(materials[0].url.endswith(".mp4")) |
| 117 | |
| 118 | # moviepy get video info |
| 119 | clip = VideoFileClip(materials[0].url) |
| 120 | try: |
| 121 | print(clip) |
| 122 | finally: |
| 123 | clip.close() |
| 124 | |
| 125 | # clean generated test video file |
| 126 | if os.path.exists(materials[0].url): |
| 127 | os.remove(materials[0].url) |
| 128 | finally: |
| 129 | if os.path.exists(safe_img_path): |
| 130 | os.remove(safe_img_path) |
| 131 | |
| 132 | def test_preprocess_video_rejects_material_outside_local_videos(self): |
| 133 | """ |
| 134 | local 素材路径来自 API 参数,不能允许任意绝对路径进入 MoviePy。 |
| 135 | 这里验证非 local_videos 白名单目录内的路径会被跳过,避免任意文件读取。 |
| 136 | """ |
| 137 | m = MaterialInfo(provider="local", url=self.test_img_path) |
| 138 | |
| 139 | materials = vd.preprocess_video([m], clip_duration=4) |
| 140 | |
| 141 | self.assertEqual(materials, []) |
| 142 | |
| 143 | def test_get_bgm_file_accepts_song_directory_filename(self): |
| 144 | """ |
| 145 | BGM 列表接口现在只暴露文件名;生成视频时应能把文件名安全解析回 |
| 146 | resource/songs 白名单目录,保持正常使用路径可用。 |
| 147 | """ |
| 148 | song_dir = utils.song_dir() |
| 149 | bgm_path = os.path.join(song_dir, "test-safe-bgm.mp3") |
| 150 | Path(bgm_path).write_bytes(b"fake-mp3") |
| 151 | |
| 152 | try: |
| 153 | self.assertEqual(vd.get_bgm_file(bgm_file="test-safe-bgm.mp3"), bgm_path) |
| 154 | finally: |
| 155 | if os.path.exists(bgm_path): |
| 156 | os.remove(bgm_path) |
| 157 | |
| 158 | def test_get_bgm_file_accepts_project_relative_song_path(self): |
| 159 | """ |
| 160 | 用户在 WebUI 中可能直接填写 ./resource/songs/xxx.mp3。该路径虽然是 |
| 161 | 项目根目录相对路径,但实际文件仍在 resource/songs 白名单目录内, |
| 162 | 应该被接受,避免自定义背景音乐被误判为不存在。 |
| 163 | """ |
| 164 | song_dir = utils.song_dir() |
| 165 | bgm_path = os.path.join(song_dir, "test-relative-bgm.mp3") |
| 166 | Path(bgm_path).write_bytes(b"fake-mp3") |
| 167 | |
| 168 | try: |
| 169 | self.assertEqual( |
| 170 | vd.get_bgm_file(bgm_file="./resource/songs/test-relative-bgm.mp3"), |
| 171 | bgm_path, |
| 172 | ) |
| 173 | finally: |
| 174 | if os.path.exists(bgm_path): |
| 175 | os.remove(bgm_path) |
| 176 | |
| 177 | def test_get_bgm_file_rejects_path_outside_song_directory(self): |
| 178 | """ |
| 179 | 用户传入的 bgm_file 不能直接作为本地路径打开,否则可能读取系统文件。 |
| 180 | 即使外部文件存在,也必须因为不在 songs 目录内被拒绝。 |
| 181 | """ |
| 182 | with tempfile.NamedTemporaryFile(suffix=".mp3") as temp_bgm: |
| 183 | self.assertEqual(vd.get_bgm_file(bgm_file=temp_bgm.name), "") |
| 184 | |
| 185 | def test_get_ffmpeg_binary_uses_configured_env_path(self): |
| 186 | """配置中显式指定 ffmpeg 时,应优先使用该路径。""" |
| 187 | with patch.dict(os.environ, {"IMAGEIO_FFMPEG_EXE": "/tmp/custom-ffmpeg"}, clear=True): |
| 188 | self.assertEqual(utils.get_ffmpeg_binary(), "/tmp/custom-ffmpeg") |
| 189 | |
| 190 | def test_get_ffmpeg_binary_falls_back_to_imageio_ffmpeg(self): |
| 191 | """ |
| 192 | Windows 便携包里系统 PATH 可能没有 ffmpeg,但 moviepy 依赖的 |
| 193 | imageio-ffmpeg 通常会提供可执行文件。这里验证该兜底路径可用。 |
| 194 | """ |
| 195 | fake_imageio_ffmpeg = types.SimpleNamespace( |
| 196 | get_ffmpeg_exe=lambda: "/tmp/bundled-ffmpeg" |
| 197 | ) |
| 198 | |
| 199 | with patch.dict(os.environ, {}, clear=True), patch.object( |
| 200 | utils.shutil, "which", return_value=None |
| 201 | ), patch.dict(sys.modules, {"imageio_ffmpeg": fake_imageio_ffmpeg}): |
| 202 | self.assertEqual(utils.get_ffmpeg_binary(), "/tmp/bundled-ffmpeg") |
| 203 | |
| 204 | def test_get_effective_video_codec_falls_back_when_encoder_missing(self): |
| 205 | """ |
| 206 | 用户选择的硬件编码器必须先经过 FFmpeg encoder 列表检测。检测不到 |
| 207 | 时直接回退 libx264,避免生成任务在写文件阶段才失败。 |
| 208 | """ |
| 209 | config.app["video_codec"] = "h264_nvenc" |
| 210 | |
| 211 | with patch.object(vd, "_ffmpeg_encoder_exists", return_value=False): |
| 212 | self.assertEqual(vd._get_effective_video_codec(), "libx264") |
| 213 | |
| 214 | def test_ffmpeg_encoder_exists_falls_back_when_probe_fails(self): |
| 215 | """ |
| 216 | Windows 上用户配置的 ffmpeg 可能因为路径损坏、权限或杀软拦截而无法 |
| 217 | 正常执行。encoder 探测失败时必须返回 False,让上层稳定回退 libx264。 |
| 218 | """ |
| 219 | with patch.object( |
| 220 | vd.subprocess, |
| 221 | "run", |
| 222 | side_effect=OSError("permission denied"), |
| 223 | ): |
| 224 | self.assertFalse(vd._ffmpeg_encoder_exists("C:/ffmpeg/bin/ffmpeg.exe", "h264_nvenc")) |
| 225 | |
| 226 | def test_write_videofile_falls_back_after_runtime_encoder_failure(self): |
| 227 | """ |
| 228 | FFmpeg 声明支持某个硬件编码器,不代表当前显卡或驱动一定可用。 |
| 229 | 首次实际编码失败后,应立即用 libx264 重试,并在本进程禁用该编码器。 |
| 230 | """ |
| 231 | |
| 232 | class _FakeClip: |
| 233 | def __init__(self): |
| 234 | self.codecs = [] |
| 235 | |
| 236 | def write_videofile(self, output_file, codec, **kwargs): |
| 237 | self.codecs.append(codec) |
| 238 | if codec == "h264_nvenc": |
| 239 | raise RuntimeError("nvenc device not available") |
| 240 | |
| 241 | fake_clip = _FakeClip() |
| 242 | |
| 243 | with patch.object(vd, "_ffmpeg_encoder_exists", return_value=True): |
| 244 | used_codec = vd._write_videofile_with_codec_fallback( |
| 245 | fake_clip, |
| 246 | "/tmp/fake.mp4", |
| 247 | codec="h264_nvenc", |
| 248 | logger=None, |
| 249 | fps=30, |
| 250 | ) |
| 251 | |
| 252 | self.assertEqual(used_codec, "libx264") |
| 253 | self.assertEqual(fake_clip.codecs, ["h264_nvenc", "libx264"]) |
| 254 | self.assertIn("h264_nvenc", vd._runtime_disabled_video_codecs) |
| 255 | |
| 256 | def test_write_videofile_does_not_disable_codec_when_fallback_also_fails(self): |
| 257 | """ |
| 258 | 如果 libx264 兜底也失败,失败原因更可能是输出路径、权限、文件占用等 |
| 259 | 通用问题,不能误判为硬件编码器不可用。 |
| 260 | """ |
| 261 | |
| 262 | class _FakeClip: |
| 263 | def write_videofile(self, output_file, codec, **kwargs): |
| 264 | raise RuntimeError(f"{codec} cannot write output") |
| 265 | |
| 266 | with patch.object(vd, "_ffmpeg_encoder_exists", return_value=True): |
| 267 | with self.assertRaises(RuntimeError): |
| 268 | vd._write_videofile_with_codec_fallback( |
| 269 | _FakeClip(), |
| 270 | "/tmp/fake.mp4", |
| 271 | codec="h264_nvenc", |
| 272 | logger=None, |
| 273 | fps=30, |
| 274 | ) |
| 275 | |
| 276 | self.assertNotIn("h264_nvenc", vd._runtime_disabled_video_codecs) |
| 277 | |
| 278 | def test_format_ffmpeg_concat_path_normalizes_windows_path(self): |
| 279 | """ |
| 280 | concat demuxer 的文件列表对 Windows 反斜杠较敏感,写入 list 前统一 |
| 281 | 转成正斜杠,并继续保留单引号转义。 |
| 282 | """ |
| 283 | with patch.object(vd.os.path, "abspath", return_value=r"C:\Users\Harry's Videos\clip.mp4"): |
| 284 | self.assertEqual( |
| 285 | vd._format_ffmpeg_concat_path(r"C:\Users\Harry's Videos\clip.mp4"), |
| 286 | "C:/Users/Harry'\\''s Videos/clip.mp4", |
| 287 | ) |
| 288 | |
| 289 | def test_concat_video_clips_falls_back_after_runtime_encoder_failure(self): |
| 290 | """ |
| 291 | 最终 ffmpeg concat 阶段也要具备同样的回退能力。这里用 mock 模拟 |
| 292 | h264_nvenc 编码失败,确认会自动再用 libx264 执行一次。 |
| 293 | """ |
| 294 | config.app["video_codec"] = "h264_nvenc" |
| 295 | |
| 296 | def fake_run(command, capture_output, text, check): |
| 297 | codec_index = command.index("-c:v") + 1 |
| 298 | codec = command[codec_index] |
| 299 | if codec == "h264_nvenc": |
| 300 | return types.SimpleNamespace( |
| 301 | returncode=1, |
| 302 | stdout="", |
| 303 | stderr="nvenc device not available", |
| 304 | ) |
| 305 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 306 | |
| 307 | with tempfile.TemporaryDirectory() as temp_dir: |
| 308 | clip_file = os.path.join(temp_dir, "clip.mp4") |
| 309 | output_file = os.path.join(temp_dir, "combined.mp4") |
| 310 | Path(clip_file).write_bytes(b"fake") |
| 311 | |
| 312 | with patch.object(vd, "_ffmpeg_encoder_exists", return_value=True): |
| 313 | with patch.object(vd.subprocess, "run", side_effect=fake_run) as run: |
| 314 | vd.concat_video_clips_with_ffmpeg( |
| 315 | clip_files=[clip_file], |
| 316 | output_file=output_file, |
| 317 | threads=1, |
| 318 | output_dir=temp_dir, |
| 319 | ) |
| 320 | |
| 321 | used_codecs = [ |
| 322 | call.args[0][call.args[0].index("-c:v") + 1] |
| 323 | for call in run.call_args_list |
| 324 | ] |
| 325 | self.assertEqual(used_codecs, ["h264_nvenc", "libx264"]) |
| 326 | self.assertIn("h264_nvenc", vd._runtime_disabled_video_codecs) |
| 327 | |
| 328 | def test_concat_video_clips_does_not_disable_codec_when_fallback_also_fails(self): |
| 329 | """ |
| 330 | concat 阶段如果 libx264 也失败,说明可能是输入 list、路径或输出权限 |
| 331 | 问题,不能把硬件编码器加入运行时禁用列表。 |
| 332 | """ |
| 333 | config.app["video_codec"] = "h264_nvenc" |
| 334 | |
| 335 | def fake_run(command, capture_output, text, check): |
| 336 | codec_index = command.index("-c:v") + 1 |
| 337 | codec = command[codec_index] |
| 338 | return types.SimpleNamespace( |
| 339 | returncode=1, |
| 340 | stdout="", |
| 341 | stderr=f"{codec} cannot write output", |
| 342 | ) |
| 343 | |
| 344 | with tempfile.TemporaryDirectory() as temp_dir: |
| 345 | clip_file = os.path.join(temp_dir, "clip.mp4") |
| 346 | output_file = os.path.join(temp_dir, "combined.mp4") |
| 347 | Path(clip_file).write_bytes(b"fake") |
| 348 | |
| 349 | with patch.object(vd, "_ffmpeg_encoder_exists", return_value=True): |
| 350 | with patch.object(vd.subprocess, "run", side_effect=fake_run): |
| 351 | with self.assertRaises(RuntimeError): |
| 352 | vd.concat_video_clips_with_ffmpeg( |
| 353 | clip_files=[clip_file], |
| 354 | output_file=output_file, |
| 355 | threads=1, |
| 356 | output_dir=temp_dir, |
| 357 | ) |
| 358 | |
| 359 | self.assertNotIn("h264_nvenc", vd._runtime_disabled_video_codecs) |
| 360 | |
| 361 | def test_open_video_clip_quietly_suppresses_moviepy_stdout(self): |
| 362 | """ |
| 363 | MoviePy 2.1.x 的 FFMPEG_VideoReader 会直接向 stdout 打印 metadata |
| 364 | 和 ffmpeg 命令。项目服务层应屏蔽这类依赖库噪声,避免用户把 |
| 365 | `audio_found: False` 误判为最终视频没有音频。 |
| 366 | """ |
| 367 | video_path = os.path.join(resources_dir, "1.png.mp4") |
| 368 | if not os.path.exists(video_path): |
| 369 | self.fail(f"test video not found: {video_path}") |
| 370 | |
| 371 | stdout = StringIO() |
| 372 | with redirect_stdout(stdout): |
| 373 | clip = vd._open_video_clip_quietly(video_path) |
| 374 | |
| 375 | try: |
| 376 | self.assertEqual(stdout.getvalue(), "") |
| 377 | self.assertIsNone(clip.audio) |
| 378 | self.assertGreater(clip.duration, 0) |
| 379 | finally: |
| 380 | vd.close_clip(clip) |
| 381 | |
| 382 | def test_combine_videos_closes_audio_clip_when_duration_read_fails(self): |
| 383 | """ |
| 384 | `combine_videos()` 只需要读取旁白音频时长。即使读取 duration |
| 385 | 时发生异常,也必须关闭 AudioFileClip,避免文件句柄泄漏。 |
| 386 | """ |
| 387 | |
| 388 | class _FakeAudioReader: |
| 389 | def __init__(self): |
| 390 | self.closed = False |
| 391 | |
| 392 | def close(self): |
| 393 | self.closed = True |
| 394 | |
| 395 | class _BrokenAudioClip: |
| 396 | def __init__(self): |
| 397 | self.reader = _FakeAudioReader() |
| 398 | |
| 399 | @property |
| 400 | def duration(self): |
| 401 | raise RuntimeError("failed to read duration") |
| 402 | |
| 403 | fake_audio_clip = _BrokenAudioClip() |
| 404 | |
| 405 | with patch.object(vd, "AudioFileClip", return_value=fake_audio_clip): |
| 406 | with self.assertRaises(RuntimeError): |
| 407 | vd.combine_videos( |
| 408 | combined_video_path="/tmp/unused-combined.mp4", |
| 409 | video_paths=[], |
| 410 | audio_file="/tmp/unused-audio.mp3", |
| 411 | ) |
| 412 | |
| 413 | self.assertTrue(fake_audio_clip.reader.closed) |
| 414 | |
| 415 | def test_combine_videos_handles_none_transition_mode(self): |
| 416 | """ |
| 417 | Ensure `combine_videos` safely handles |
| 418 | `video_transition_mode=None`. |
| 419 | """ |
| 420 | class _FakeAudioClip: |
| 421 | @property |
| 422 | def duration(self): |
| 423 | return 10.0 |
| 424 | |
| 425 | def close(self): |
| 426 | pass |
| 427 | |
| 428 | with tempfile.TemporaryDirectory() as temp_dir: |
| 429 | combined_video_path = os.path.join(temp_dir, "combined.mp4") |
| 430 | audio_file = os.path.join(temp_dir, "audio.mp3") |
| 431 | |
| 432 | with patch.object(vd, "AudioFileClip", return_value=_FakeAudioClip()): |
| 433 | # Use empty video_paths to avoid heavy video processing while |
| 434 | # still exercising transition mode normalization logic. |
| 435 | result = vd.combine_videos( |
| 436 | combined_video_path=combined_video_path, |
| 437 | video_paths=[], |
| 438 | audio_file=audio_file, |
| 439 | video_transition_mode=None, |
| 440 | ) |
| 441 | self.assertEqual(result, combined_video_path) |
| 442 | |
| 443 | def test_combine_videos_keeps_small_duration_safety_margin(self): |
| 444 | """ |
| 445 | 音频和素材累计时长刚好相等时,仍应继续追加一个短片段作为安全余量。 |
| 446 | |
| 447 | FFmpeg 按帧率拼接后可能让最终视频比理论时长短几十毫秒。如果这里 |
| 448 | 在 10.0s == 10.0s 时立即停止,成片末尾就可能出现音频还在播放但 |
| 449 | 视频素材已经结束的边界问题。 |
| 450 | """ |
| 451 | |
| 452 | class _FakeAudioClip: |
| 453 | duration = 10.0 |
| 454 | |
| 455 | def close(self): |
| 456 | pass |
| 457 | |
| 458 | class _FakeVideoClip: |
| 459 | def __init__(self, duration): |
| 460 | self.duration = duration |
| 461 | self.size = (1080, 1920) |
| 462 | self.w = 1080 |
| 463 | self.h = 1920 |
| 464 | |
| 465 | def subclipped(self, start_time, end_time): |
| 466 | return _FakeVideoClip(end_time - start_time) |
| 467 | |
| 468 | video_durations = { |
| 469 | "clip-1.mp4": 3.0, |
| 470 | "clip-2.mp4": 4.0, |
| 471 | "clip-3.mp4": 3.0, |
| 472 | "clip-4.mp4": 2.0, |
| 473 | } |
| 474 | |
| 475 | def _open_fake_video_clip(video_path): |
| 476 | return _FakeVideoClip(video_durations[video_path]) |
| 477 | |
| 478 | with tempfile.TemporaryDirectory() as temp_dir: |
| 479 | combined_video_path = os.path.join(temp_dir, "combined.mp4") |
| 480 | |
| 481 | with patch.object(vd, "AudioFileClip", return_value=_FakeAudioClip()): |
| 482 | with patch.object( |
| 483 | vd, "_open_video_clip_quietly", side_effect=_open_fake_video_clip |
| 484 | ): |
| 485 | with patch.object( |
| 486 | vd, "_write_videofile_with_codec_fallback" |
| 487 | ) as write_mock: |
| 488 | with patch.object(vd, "concat_video_clips_with_ffmpeg"): |
| 489 | with patch.object(vd, "delete_files"): |
| 490 | result = vd.combine_videos( |
| 491 | combined_video_path=combined_video_path, |
| 492 | video_paths=list(video_durations.keys()), |
| 493 | audio_file=os.path.join(temp_dir, "audio.mp3"), |
| 494 | video_aspect=vd.VideoAspect.portrait, |
| 495 | video_concat_mode=vd.VideoConcatMode.sequential, |
| 496 | video_transition_mode=None, |
| 497 | max_clip_duration=10, |
| 498 | ) |
| 499 | |
| 500 | self.assertEqual(result, combined_video_path) |
| 501 | self.assertEqual(write_mock.call_count, 4) |
| 502 | |
| 503 | def test_prioritize_unique_source_clips_uses_each_source_before_reuse(self): |
| 504 | """ |
| 505 | 随机模式下,一个长素材会被拆成多个片段。调度层应先让每个源素材 |
| 506 | 至少出现一次,再使用同一源素材的其他切片,降低用户感知到的重复。 |
| 507 | """ |
| 508 | clips = [ |
| 509 | vd.SubClippedVideoClip("a.mp4", 0, 4, source_file_path="a.mp4"), |
| 510 | vd.SubClippedVideoClip("a.mp4", 4, 8, source_file_path="a.mp4"), |
| 511 | vd.SubClippedVideoClip("b.mp4", 0, 4, source_file_path="b.mp4"), |
| 512 | vd.SubClippedVideoClip("b.mp4", 4, 8, source_file_path="b.mp4"), |
| 513 | vd.SubClippedVideoClip("c.mp4", 0, 4, source_file_path="c.mp4"), |
| 514 | ] |
| 515 | |
| 516 | ordered_clips = vd._prioritize_unique_source_clips( |
| 517 | subclipped_items=clips, |
| 518 | concat_mode=vd.VideoConcatMode.random, |
| 519 | ) |
| 520 | |
| 521 | self.assertCountEqual(ordered_clips, clips) |
| 522 | first_round_sources = [clip.source_file_path for clip in ordered_clips[:3]] |
| 523 | self.assertCountEqual(first_round_sources, ["a.mp4", "b.mp4", "c.mp4"]) |
| 524 | |
| 525 | def test_prioritize_unique_source_clips_keeps_sequential_order(self): |
| 526 | """ |
| 527 | 顺序模式本身只取每个素材的首段,不应被随机调度逻辑改变顺序。 |
| 528 | """ |
| 529 | clips = [ |
| 530 | vd.SubClippedVideoClip("a.mp4", 0, 4, source_file_path="a.mp4"), |
| 531 | vd.SubClippedVideoClip("b.mp4", 0, 4, source_file_path="b.mp4"), |
| 532 | vd.SubClippedVideoClip("c.mp4", 0, 4, source_file_path="c.mp4"), |
| 533 | ] |
| 534 | |
| 535 | ordered_clips = vd._prioritize_unique_source_clips( |
| 536 | subclipped_items=clips, |
| 537 | concat_mode=vd.VideoConcatMode.sequential, |
| 538 | ) |
| 539 | |
| 540 | self.assertEqual(ordered_clips, clips) |
| 541 | |
| 542 | def test_prioritize_unique_source_clips_prefers_long_primary_clip(self): |
| 543 | """ |
| 544 | 同一个源素材的最后一个切片可能短于目标片段时长。首轮去重时应优先 |
| 545 | 选择较长片段,否则会因为累计时长不足而提前复用素材。 |
| 546 | """ |
| 547 | short_tail = vd.SubClippedVideoClip( |
| 548 | "a.mp4", 6, 6.5, source_file_path="a.mp4" |
| 549 | ) |
| 550 | full_clip = vd.SubClippedVideoClip( |
| 551 | "a.mp4", 0, 3, source_file_path="a.mp4" |
| 552 | ) |
| 553 | other_source = vd.SubClippedVideoClip( |
| 554 | "b.mp4", 0, 3, source_file_path="b.mp4" |
| 555 | ) |
| 556 | |
| 557 | ordered_clips = vd._prioritize_unique_source_clips( |
| 558 | subclipped_items=[short_tail, full_clip, other_source], |
| 559 | concat_mode=vd.VideoConcatMode.random, |
| 560 | ) |
| 561 | |
| 562 | first_a_clip = next( |
| 563 | clip for clip in ordered_clips if clip.source_file_path == "a.mp4" |
| 564 | ) |
| 565 | self.assertEqual(first_a_clip, full_clip) |
| 566 | |
| 567 | def test_wrap_text(self): |
| 568 | """test text wrapping function""" |
| 569 | try: |
| 570 | font_path = os.path.join(utils.font_dir(), "STHeitiMedium.ttc") |
| 571 | if not os.path.exists(font_path): |
| 572 | self.fail(f"font file not found: {font_path}") |
| 573 | |
| 574 | # test english text wrapping |
| 575 | test_text_en = "This is a test text for wrapping long sentences in english language" |
| 576 | |
| 577 | wrapped_text_en, text_height_en = vd.wrap_text( |
| 578 | text=test_text_en, |
| 579 | max_width=300, |
| 580 | font=font_path, |
| 581 | fontsize=30 |
| 582 | ) |
| 583 | print(wrapped_text_en, text_height_en) |
| 584 | # verify text is wrapped |
| 585 | self.assertIn("\n", wrapped_text_en) |
| 586 | |
| 587 | # test chinese text wrapping |
| 588 | test_text_zh = "这是一段用来测试中文长句换行的文本内容,应该会根据宽度限制进行换行处理" |
| 589 | wrapped_text_zh, text_height_zh = vd.wrap_text( |
| 590 | text=test_text_zh, |
| 591 | max_width=300, |
| 592 | font=font_path, |
| 593 | fontsize=30 |
| 594 | ) |
| 595 | print(wrapped_text_zh, text_height_zh) |
| 596 | # verify chinese text is wrapped |
| 597 | self.assertIn("\n", wrapped_text_zh) |
| 598 | except Exception as e: |
| 599 | self.fail(f"test wrap_text failed: {str(e)}") |
| 600 | |
| 601 | def test_rounded_subtitle_background_clip_has_transparent_corners(self): |
| 602 | """ |
| 603 | 圆角字幕背景只在用户显式开启时使用。这里直接验证生成的 RGBA |
| 604 | 背景具备透明圆角和半透明中心,避免后续改动把圆角效果退化成实心矩形。 |
| 605 | """ |
| 606 | clip = vd._rounded_subtitle_background_clip( |
| 607 | width=120, |
| 608 | height=48, |
| 609 | color="#123456", |
| 610 | alpha=140, |
| 611 | radius=16, |
| 612 | ) |
| 613 | try: |
| 614 | frame = clip.get_frame(0) |
| 615 | mask = clip.mask.get_frame(0) |
| 616 | |
| 617 | self.assertEqual(frame.shape[0:2], (48, 120)) |
| 618 | self.assertEqual(tuple(frame[24, 60]), (18, 52, 86)) |
| 619 | self.assertEqual(mask[0, 0], 0) |
| 620 | self.assertGreater(mask[24, 60], 0.5) |
| 621 | self.assertLess(mask[24, 60], 0.6) |
| 622 | finally: |
| 623 | clip.close() |
| 624 | |
| 625 | def test_get_temp_audio_dir_returns_system_temp_on_windows(self): |
| 626 | with patch("sys.platform", "win32"): |
| 627 | result = vd._get_temp_audio_dir("/some/output/dir") |
| 628 | self.assertEqual(result, tempfile.gettempdir()) |
| 629 | |
| 630 | def test_get_temp_audio_dir_returns_output_dir_on_non_windows(self): |
| 631 | for platform in ("linux", "darwin"): |
| 632 | with self.subTest(platform=platform): |
| 633 | with patch("sys.platform", platform): |
| 634 | result = vd._get_temp_audio_dir("/some/output/dir") |
| 635 | self.assertEqual(result, "/some/output/dir") |
| 636 | |
| 637 | |
| 638 | if __name__ == "__main__": |
| 639 | unittest.main() |
| 640 |