| 1 | import glob |
| 2 | import itertools |
| 3 | import io |
| 4 | import os |
| 5 | import random |
| 6 | import gc |
| 7 | import shutil |
| 8 | import subprocess |
| 9 | import sys |
| 10 | import tempfile |
| 11 | from contextlib import redirect_stdout |
| 12 | from functools import lru_cache |
| 13 | from typing import List |
| 14 | from loguru import logger |
| 15 | import numpy as np |
| 16 | from moviepy import ( |
| 17 | AudioFileClip, |
| 18 | ColorClip, |
| 19 | CompositeAudioClip, |
| 20 | CompositeVideoClip, |
| 21 | ImageClip, |
| 22 | TextClip, |
| 23 | VideoFileClip, |
| 24 | afx, |
| 25 | ) |
| 26 | from moviepy.video.tools.subtitles import SubtitlesClip |
| 27 | from PIL import Image, ImageDraw, ImageFont |
| 28 | |
| 29 | from app.config import config |
| 30 | from app.models import const |
| 31 | from app.models.schema import ( |
| 32 | MaterialInfo, |
| 33 | VideoAspect, |
| 34 | VideoConcatMode, |
| 35 | VideoParams, |
| 36 | VideoTransitionMode, |
| 37 | ) |
| 38 | from app.services.utils import video_effects |
| 39 | from app.utils import file_security, utils |
| 40 | |
| 41 | class SubClippedVideoClip: |
| 42 | def __init__( |
| 43 | self, |
| 44 | file_path, |
| 45 | start_time=None, |
| 46 | end_time=None, |
| 47 | width=None, |
| 48 | height=None, |
| 49 | duration=None, |
| 50 | source_file_path=None, |
| 51 | ): |
| 52 | self.file_path = file_path |
| 53 | self.start_time = start_time |
| 54 | self.end_time = end_time |
| 55 | self.width = width |
| 56 | self.height = height |
| 57 | self.source_file_path = source_file_path or file_path |
| 58 | if duration is None: |
| 59 | self.duration = end_time - start_time |
| 60 | else: |
| 61 | self.duration = duration |
| 62 | |
| 63 | def __str__(self): |
| 64 | return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})" |
| 65 | |
| 66 | |
| 67 | audio_codec = "aac" |
| 68 | # Docker 里的 ffmpeg/AAC 组合在默认配置下更容易出现音频质量波动, |
| 69 | # 这里显式抬高音频码率,避免成片阶段因为默认值过低而引入明显失真。 |
| 70 | audio_bitrate = "192k" |
| 71 | fps = 30 |
| 72 | # FFmpeg 按帧率拼接/转码时,最终时长可能比 MoviePy 读到的理论时长短几十毫秒。 |
| 73 | # 这里给视频素材多留一个很小的安全余量,避免音频末尾因为帧舍入出现黑屏、 |
| 74 | # 卡顿或最后一小段旁白没有画面的情况。 |
| 75 | _VIDEO_DURATION_SAFETY_MARGIN = 0.1 |
| 76 | _BGM_EXTENSIONS = (".mp3",) |
| 77 | _DEFAULT_VIDEO_CODEC = "libx264" |
| 78 | _SUPPORTED_VIDEO_CODECS = ( |
| 79 | "libx264", |
| 80 | "h264_nvenc", |
| 81 | "h264_amf", |
| 82 | "h264_qsv", |
| 83 | "h264_mf", |
| 84 | "h264_videotoolbox", |
| 85 | ) |
| 86 | _runtime_disabled_video_codecs = set() |
| 87 | |
| 88 | |
| 89 | def _get_required_video_duration(audio_duration: float) -> float: |
| 90 | """ |
| 91 | 返回视频素材拼接的目标时长。 |
| 92 | |
| 93 | 使用场景:合成视频时需要素材时长覆盖旁白音频。只做到“刚好等于” |
| 94 | 音频时长时,FFmpeg 可能因为帧率舍入让最终视频略短,因此统一加一个 |
| 95 | 轻量余量。函数独立出来,便于测试和后续按实际反馈调整余量大小。 |
| 96 | """ |
| 97 | return max(0.0, float(audio_duration) + _VIDEO_DURATION_SAFETY_MARGIN) |
| 98 | |
| 99 | |
| 100 | def _prioritize_unique_source_clips( |
| 101 | subclipped_items: List[SubClippedVideoClip], |
| 102 | concat_mode: VideoConcatMode, |
| 103 | ) -> List[SubClippedVideoClip]: |
| 104 | """ |
| 105 | 优先让每个源素材只出现一次,降低成片里同一素材反复出现的概率。 |
| 106 | |
| 107 | 线上素材经常会遇到“一个长视频被切成多个短片段”的情况。旧逻辑在 |
| 108 | random 模式下直接打乱所有短片段,导致同一个源视频的多个切片可能 |
| 109 | 分布在开头和中间,用户会感知为素材重复。本函数只调整片段顺序: |
| 110 | 先放每个源文件里最长的一个片段,剩余片段作为兜底;当素材总时长不足时, |
| 111 | 仍然允许后续片段补齐音频长度,避免破坏视频生成成功率。优先选择最长 |
| 112 | 片段是为了避免随机选中视频尾部的零碎短片段,导致明明有足够素材却过早复用。 |
| 113 | """ |
| 114 | if not subclipped_items: |
| 115 | return [] |
| 116 | |
| 117 | concat_mode_value = getattr(concat_mode, "value", concat_mode) |
| 118 | if concat_mode_value != VideoConcatMode.random.value: |
| 119 | return subclipped_items |
| 120 | |
| 121 | grouped_items: dict[str, list[SubClippedVideoClip]] = {} |
| 122 | for item in subclipped_items: |
| 123 | grouped_items.setdefault(item.source_file_path, []).append(item) |
| 124 | |
| 125 | primary_items = [] |
| 126 | overflow_items = [] |
| 127 | for items in grouped_items.values(): |
| 128 | primary_item = max(items, key=lambda item: item.duration) |
| 129 | primary_items.append(primary_item) |
| 130 | overflow_items.extend(item for item in items if item is not primary_item) |
| 131 | |
| 132 | random.shuffle(primary_items) |
| 133 | random.shuffle(overflow_items) |
| 134 | logger.info( |
| 135 | "prioritized unique video materials, " |
| 136 | f"sources: {len(grouped_items)}, " |
| 137 | f"primary clips: {len(primary_items)}, " |
| 138 | f"fallback clips: {len(overflow_items)}" |
| 139 | ) |
| 140 | return primary_items + overflow_items |
| 141 | |
| 142 | |
| 143 | def get_ffmpeg_binary(): |
| 144 | """ |
| 145 | 兼容历史上直接从 video 服务读取 FFmpeg 路径的调用方。 |
| 146 | |
| 147 | 真正的解析逻辑已经抽到 `app.utils.utils.get_ffmpeg_binary()`,视频、语音 |
| 148 | 和后续新增链路都应复用同一套优先级;这里保留薄包装,避免外部脚本或 |
| 149 | 旧测试直接导入 `app.services.video.get_ffmpeg_binary` 时出现 AttributeError。 |
| 150 | """ |
| 151 | return utils.get_ffmpeg_binary() |
| 152 | |
| 153 | |
| 154 | def _get_configured_video_codec() -> str: |
| 155 | """ |
| 156 | 读取用户配置的视频编码器。 |
| 157 | |
| 158 | 该配置面向高级用户,用于尝试启用 NVENC/AMF/QSV/VideoToolbox 等硬件 |
| 159 | 编码。这里刻意只允许固定白名单,避免开放任意 FFmpeg 参数后,用户填错 |
| 160 | 参数导致输出格式不可控,甚至让生成任务在后续阶段才失败。 |
| 161 | """ |
| 162 | configured_codec = str( |
| 163 | config.app.get("video_codec", _DEFAULT_VIDEO_CODEC) or _DEFAULT_VIDEO_CODEC |
| 164 | ).strip() |
| 165 | if configured_codec not in _SUPPORTED_VIDEO_CODECS: |
| 166 | logger.warning( |
| 167 | f"unsupported video codec configured: {configured_codec}, " |
| 168 | f"fallback to {_DEFAULT_VIDEO_CODEC}" |
| 169 | ) |
| 170 | return _DEFAULT_VIDEO_CODEC |
| 171 | return configured_codec |
| 172 | |
| 173 | |
| 174 | @lru_cache(maxsize=16) |
| 175 | def _ffmpeg_encoder_exists(ffmpeg_binary: str, codec: str) -> bool: |
| 176 | """ |
| 177 | 检查当前 FFmpeg 是否声明支持指定编码器。 |
| 178 | |
| 179 | 这只能证明 FFmpeg 编译时包含该 encoder,不能证明当前机器硬件和驱动 |
| 180 | 一定可用。因此实际编码失败时仍会再回退到 libx264。 |
| 181 | """ |
| 182 | try: |
| 183 | result = subprocess.run( |
| 184 | [ffmpeg_binary, "-hide_banner", "-encoders"], |
| 185 | capture_output=True, |
| 186 | text=True, |
| 187 | check=False, |
| 188 | timeout=10, |
| 189 | ) |
| 190 | except (OSError, subprocess.TimeoutExpired) as exc: |
| 191 | logger.warning( |
| 192 | "failed to inspect ffmpeg encoders, " |
| 193 | f"fallback to {_DEFAULT_VIDEO_CODEC}: {str(exc)}" |
| 194 | ) |
| 195 | return False |
| 196 | |
| 197 | if result.returncode != 0: |
| 198 | logger.warning( |
| 199 | "failed to inspect ffmpeg encoders, " |
| 200 | f"fallback to {_DEFAULT_VIDEO_CODEC}: {(result.stderr or result.stdout or '').strip()}" |
| 201 | ) |
| 202 | return False |
| 203 | return codec in result.stdout |
| 204 | |
| 205 | |
| 206 | def _get_effective_video_codec(preferred_codec: str | None = None) -> str: |
| 207 | """ |
| 208 | 返回本次实际使用的视频编码器。 |
| 209 | |
| 210 | 用户选择硬件编码器时,先做 FFmpeg encoder 列表检测;如果本进程里已经 |
| 211 | 实际编码失败过,也直接回退,避免一个任务里每个片段都重复失败。 |
| 212 | """ |
| 213 | selected_codec = preferred_codec or _get_configured_video_codec() |
| 214 | if selected_codec == _DEFAULT_VIDEO_CODEC: |
| 215 | return _DEFAULT_VIDEO_CODEC |
| 216 | |
| 217 | if selected_codec in _runtime_disabled_video_codecs: |
| 218 | logger.warning( |
| 219 | f"video codec {selected_codec} was disabled after a runtime failure, " |
| 220 | f"fallback to {_DEFAULT_VIDEO_CODEC}" |
| 221 | ) |
| 222 | return _DEFAULT_VIDEO_CODEC |
| 223 | |
| 224 | ffmpeg_binary = utils.get_ffmpeg_binary() |
| 225 | if not _ffmpeg_encoder_exists(ffmpeg_binary, selected_codec): |
| 226 | logger.warning( |
| 227 | f"ffmpeg encoder {selected_codec} is not available, " |
| 228 | f"fallback to {_DEFAULT_VIDEO_CODEC}" |
| 229 | ) |
| 230 | return _DEFAULT_VIDEO_CODEC |
| 231 | |
| 232 | return selected_codec |
| 233 | |
| 234 | |
| 235 | def _disable_runtime_video_codec(codec: str, reason: str): |
| 236 | if codec == _DEFAULT_VIDEO_CODEC: |
| 237 | return |
| 238 | _runtime_disabled_video_codecs.add(codec) |
| 239 | logger.warning( |
| 240 | f"video codec {codec} failed, fallback to {_DEFAULT_VIDEO_CODEC}. " |
| 241 | f"reason: {reason}" |
| 242 | ) |
| 243 | |
| 244 | |
| 245 | def _get_temp_audio_dir(output_dir: str) -> str: |
| 246 | """ |
| 247 | Return the directory to use for MoviePy's temporary audio file. |
| 248 | |
| 249 | On Windows, Windows Defender can lock files written to the task output |
| 250 | directory while scanning them, causing MoviePy to fail with a |
| 251 | PermissionError (WinError 32) on the TEMP_MPY_wvf_snd temp file and |
| 252 | leaving the final MP4 at 0 bytes. Using the system temp directory |
| 253 | sidesteps the scan without changing behaviour on other platforms. |
| 254 | |
| 255 | On Linux/macOS/Docker the output directory is returned unchanged so |
| 256 | existing behaviour is preserved. |
| 257 | """ |
| 258 | if sys.platform == "win32": |
| 259 | return tempfile.gettempdir() |
| 260 | return output_dir |
| 261 | |
| 262 | |
| 263 | def _fallback_write_videofile(clip, output_file: str, failed_codec: str, reason: str, **kwargs): |
| 264 | """ |
| 265 | 硬件编码失败后用 libx264 重试,只有重试成功才禁用该硬件编码器。 |
| 266 | |
| 267 | Windows 上 FFmpeg 失败原因比较复杂:可能是显卡/驱动不支持,也可能是输出 |
| 268 | 文件被占用、目录权限、杀软拦截等通用 IO 问题。只有 libx264 能成功写出时, |
| 269 | 才能判断原始失败大概率来自硬件编码器本身,避免误伤后续任务。 |
| 270 | """ |
| 271 | clip.write_videofile(output_file, codec=_DEFAULT_VIDEO_CODEC, **kwargs) |
| 272 | _disable_runtime_video_codec(failed_codec, reason) |
| 273 | return _DEFAULT_VIDEO_CODEC |
| 274 | |
| 275 | |
| 276 | def _write_videofile_with_codec_fallback(clip, output_file: str, codec: str, **kwargs): |
| 277 | """ |
| 278 | 使用指定编码器写出视频,失败时自动用 libx264 重试一次。 |
| 279 | |
| 280 | 硬件编码器是否可用不仅取决于 FFmpeg,还取决于显卡、驱动和当前运行环境。 |
| 281 | 生成任务不能因为高级编码器不可用而整体失败,所以这里把回退集中处理。 |
| 282 | """ |
| 283 | effective_codec = _get_effective_video_codec(codec) |
| 284 | try: |
| 285 | clip.write_videofile(output_file, codec=effective_codec, **kwargs) |
| 286 | return effective_codec |
| 287 | except Exception as exc: |
| 288 | if effective_codec == _DEFAULT_VIDEO_CODEC: |
| 289 | raise |
| 290 | return _fallback_write_videofile( |
| 291 | clip, |
| 292 | output_file, |
| 293 | failed_codec=effective_codec, |
| 294 | reason=str(exc), |
| 295 | **kwargs, |
| 296 | ) |
| 297 | |
| 298 | |
| 299 | def _escape_ffmpeg_concat_path(file_path: str) -> str: |
| 300 | # concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。 |
| 301 | return file_path.replace("'", "'\\''") |
| 302 | |
| 303 | |
| 304 | def _format_ffmpeg_concat_path(file_path: str) -> str: |
| 305 | """ |
| 306 | 生成 concat demuxer 文件列表中的路径。 |
| 307 | |
| 308 | FFmpeg 官方文档要求 concat list 中的特殊字符和空格需要转义;Windows |
| 309 | 绝对路径里的反斜杠也容易被解析成转义字符。这里统一转成正斜杠形式, |
| 310 | 让 `C:\\Users\\...` 变成 `C:/Users/...`,再处理单引号,兼容 macOS/Linux。 |
| 311 | """ |
| 312 | absolute_path = os.path.abspath(file_path) |
| 313 | return _escape_ffmpeg_concat_path(absolute_path.replace("\\", "/")) |
| 314 | |
| 315 | |
| 316 | def concat_video_clips_with_ffmpeg( |
| 317 | clip_files: List[str], output_file: str, threads: int, output_dir: str |
| 318 | ): |
| 319 | concat_list_file = os.path.join(output_dir, "ffmpeg-concat-list.txt") |
| 320 | with open(concat_list_file, "w", encoding="utf-8") as fp: |
| 321 | for clip_file in clip_files: |
| 322 | fp.write(f"file '{_format_ffmpeg_concat_path(clip_file)}'\n") |
| 323 | |
| 324 | def build_command(codec: str) -> list[str]: |
| 325 | return [ |
| 326 | utils.get_ffmpeg_binary(), |
| 327 | "-y", |
| 328 | "-f", |
| 329 | "concat", |
| 330 | "-safe", |
| 331 | "0", |
| 332 | "-i", |
| 333 | concat_list_file, |
| 334 | "-c:v", |
| 335 | codec, |
| 336 | "-threads", |
| 337 | str(threads or 2), |
| 338 | "-pix_fmt", |
| 339 | "yuv420p", |
| 340 | output_file, |
| 341 | ] |
| 342 | |
| 343 | def run_concat(codec: str): |
| 344 | command = build_command(codec) |
| 345 | # 使用 ffmpeg 只做一次串联与编码,避免 MoviePy 逐段合并时反复重编码, |
| 346 | # 从而降低画质劣化与颜色偏移风险。 |
| 347 | result = subprocess.run( |
| 348 | command, |
| 349 | capture_output=True, |
| 350 | text=True, |
| 351 | check=False, |
| 352 | ) |
| 353 | if result.returncode != 0: |
| 354 | error_message = (result.stderr or result.stdout or "").strip() |
| 355 | raise RuntimeError(error_message or "ffmpeg concat failed") |
| 356 | return codec |
| 357 | |
| 358 | try: |
| 359 | effective_codec = _get_effective_video_codec() |
| 360 | try: |
| 361 | return run_concat(effective_codec) |
| 362 | except Exception as exc: |
| 363 | if effective_codec == _DEFAULT_VIDEO_CODEC: |
| 364 | raise |
| 365 | result_codec = run_concat(_DEFAULT_VIDEO_CODEC) |
| 366 | _disable_runtime_video_codec(effective_codec, str(exc)) |
| 367 | return result_codec |
| 368 | finally: |
| 369 | delete_files(concat_list_file) |
| 370 | |
| 371 | |
| 372 | def _sanitize_image_file(image_path: str) -> str: |
| 373 | # 某些本地图片虽然能被 Pillow 打开,但会因为损坏的 EXIF/eXIf 元数据导致 |
| 374 | # ImageClip 在解析阶段直接抛异常。这里重新导出一份“干净图片”,把坏元数据剥离掉。 |
| 375 | image_root, _ = os.path.splitext(image_path) |
| 376 | sanitized_path = f"{image_root}.sanitized.png" |
| 377 | |
| 378 | with Image.open(image_path) as image: |
| 379 | image.load() |
| 380 | # 统一导出为 PNG,避免 JPEG/PNG 不同元数据路径继续把坏块带过去。 |
| 381 | cleaned_image = Image.new(image.mode, image.size) |
| 382 | cleaned_image.putdata(list(image.getdata())) |
| 383 | cleaned_image.save(sanitized_path) |
| 384 | |
| 385 | return sanitized_path |
| 386 | |
| 387 | |
| 388 | def _open_image_clip_with_fallback(image_path: str): |
| 389 | # 优先直接打开原始图片;如果因为损坏元数据失败,再尝试生成无元数据副本。 |
| 390 | try: |
| 391 | return ImageClip(image_path), image_path |
| 392 | except Exception as exc: |
| 393 | logger.warning( |
| 394 | f"failed to open image directly, trying sanitized copy: {image_path}, error: {str(exc)}" |
| 395 | ) |
| 396 | sanitized_path = _sanitize_image_file(image_path) |
| 397 | return ImageClip(sanitized_path), sanitized_path |
| 398 | |
| 399 | |
| 400 | def _open_video_clip_quietly(video_path: str, audio: bool = False) -> VideoFileClip: |
| 401 | """ |
| 402 | 安静地打开视频文件,避免 MoviePy 2.1.x 把 ffmpeg 探测信息直接打印到 stdout。 |
| 403 | |
| 404 | 背景: |
| 405 | 当前依赖版本的 `FFMPEG_VideoReader` 内部存在 `print(self.infos)` 和 |
| 406 | `print(ffmpeg command)`,读取无音轨的中间视频时会输出 |
| 407 | `audio_found: False`。这只是输入素材 metadata,不代表最终成片没有音频, |
| 408 | 但会误导 WebUI/终端用户以为生成失败。 |
| 409 | |
| 410 | 实现: |
| 411 | 1. 只在打开 VideoFileClip 的短窗口内重定向 stdout; |
| 412 | 2. 默认 `audio=False`,因为项目视频素材阶段不需要保留素材原声, |
| 413 | 最终音频会在 `generate_video()` 阶段统一挂载; |
| 414 | 3. 如果依赖库确实输出了内容,降级为 debug 日志,便于必要时排查。 |
| 415 | """ |
| 416 | captured_stdout = io.StringIO() |
| 417 | with redirect_stdout(captured_stdout): |
| 418 | clip = VideoFileClip(video_path, audio=audio) |
| 419 | |
| 420 | moviepy_stdout = captured_stdout.getvalue().strip() |
| 421 | if moviepy_stdout: |
| 422 | logger.debug( |
| 423 | "suppressed MoviePy video reader stdout for " |
| 424 | f"{video_path}, chars: {len(moviepy_stdout)}" |
| 425 | ) |
| 426 | |
| 427 | return clip |
| 428 | |
| 429 | |
| 430 | def close_clip(clip): |
| 431 | if clip is None: |
| 432 | return |
| 433 | |
| 434 | try: |
| 435 | # close main resources |
| 436 | if hasattr(clip, 'reader') and clip.reader is not None: |
| 437 | clip.reader.close() |
| 438 | |
| 439 | # close audio resources |
| 440 | if hasattr(clip, 'audio') and clip.audio is not None: |
| 441 | if hasattr(clip.audio, 'reader') and clip.audio.reader is not None: |
| 442 | clip.audio.reader.close() |
| 443 | del clip.audio |
| 444 | |
| 445 | # close mask resources |
| 446 | if hasattr(clip, 'mask') and clip.mask is not None: |
| 447 | if hasattr(clip.mask, 'reader') and clip.mask.reader is not None: |
| 448 | clip.mask.reader.close() |
| 449 | del clip.mask |
| 450 | |
| 451 | # handle child clips in composite clips |
| 452 | if hasattr(clip, 'clips') and clip.clips: |
| 453 | for child_clip in clip.clips: |
| 454 | if child_clip is not clip: # avoid possible circular references |
| 455 | close_clip(child_clip) |
| 456 | |
| 457 | # clear clip list |
| 458 | if hasattr(clip, 'clips'): |
| 459 | clip.clips = [] |
| 460 | |
| 461 | except Exception as e: |
| 462 | logger.error(f"failed to close clip: {str(e)}") |
| 463 | |
| 464 | del clip |
| 465 | gc.collect() |
| 466 | |
| 467 | def delete_files(files: List[str] | str): |
| 468 | if isinstance(files, str): |
| 469 | files = [files] |
| 470 | |
| 471 | for file in files: |
| 472 | try: |
| 473 | os.remove(file) |
| 474 | except Exception as e: |
| 475 | logger.debug(f"failed to delete file {file}: {str(e)}") |
| 476 | |
| 477 | |
| 478 | def _resolve_bgm_file_path(song_dir: str, bgm_file: str) -> str: |
| 479 | # 背景音乐只允许读取 resource/songs 目录内的文件,避免用户输入任意路径后 |
| 480 | # 被 MoviePy 打开。这里兼容两种常见输入: |
| 481 | # 1. output000.mp3:来自 BGM 列表或用户只填写文件名 |
| 482 | # 2. ./resource/songs/output000.mp3:用户按项目目录结构填写的相对路径 |
| 483 | # 两种写法最终都会再次通过 resource/songs 白名单校验,不能绕过目录限制。 |
| 484 | try: |
| 485 | return file_security.resolve_path_within_directory(song_dir, bgm_file) |
| 486 | except ValueError as song_dir_exc: |
| 487 | if os.path.isabs(bgm_file): |
| 488 | raise song_dir_exc |
| 489 | |
| 490 | project_relative_file = os.path.join(utils.root_dir(), bgm_file) |
| 491 | try: |
| 492 | return file_security.resolve_path_within_directory( |
| 493 | song_dir, project_relative_file |
| 494 | ) |
| 495 | except ValueError as root_dir_exc: |
| 496 | raise ValueError(str(root_dir_exc)) from song_dir_exc |
| 497 | |
| 498 | |
| 499 | def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""): |
| 500 | if not bgm_type: |
| 501 | return "" |
| 502 | |
| 503 | if bgm_file: |
| 504 | song_dir = utils.song_dir() |
| 505 | try: |
| 506 | resolved_bgm_file = _resolve_bgm_file_path(song_dir, bgm_file) |
| 507 | except ValueError as exc: |
| 508 | # API 请求里的 bgm_file 来自用户输入,不能直接把任意绝对路径交给 |
| 509 | # MoviePy 打开。这里强制限制到 resource/songs 目录,阻止读取 |
| 510 | # /etc/passwd、配置文件、密钥等非背景音乐文件。 |
| 511 | logger.warning( |
| 512 | f"reject unsafe bgm file: {bgm_file}, song_dir: {song_dir}, error: {str(exc)}" |
| 513 | ) |
| 514 | return "" |
| 515 | |
| 516 | if not resolved_bgm_file.lower().endswith(_BGM_EXTENSIONS): |
| 517 | logger.warning(f"reject unsupported bgm file extension: {resolved_bgm_file}") |
| 518 | return "" |
| 519 | |
| 520 | return resolved_bgm_file |
| 521 | |
| 522 | if bgm_type == "random": |
| 523 | suffix = "*.mp3" |
| 524 | song_dir = utils.song_dir() |
| 525 | files = glob.glob(os.path.join(song_dir, suffix)) |
| 526 | # 当背景音乐目录为空时,直接回退为“不使用 BGM”,避免 random.choice([]) 抛异常。 |
| 527 | if not files: |
| 528 | logger.warning(f"no bgm files found in song directory: {song_dir}") |
| 529 | return "" |
| 530 | return random.choice(files) |
| 531 | |
| 532 | return "" |
| 533 | |
| 534 | |
| 535 | def combine_videos( |
| 536 | combined_video_path: str, |
| 537 | video_paths: List[str], |
| 538 | audio_file: str, |
| 539 | video_aspect: VideoAspect = VideoAspect.portrait, |
| 540 | video_concat_mode: VideoConcatMode = VideoConcatMode.random, |
| 541 | video_transition_mode: VideoTransitionMode = None, |
| 542 | max_clip_duration: int = 5, |
| 543 | threads: int = 2, |
| 544 | ) -> str: |
| 545 | audio_clip = AudioFileClip(audio_file) |
| 546 | try: |
| 547 | # 这里只需要读取旁白音频时长来决定素材视频拼接长度;后续不会再使用 |
| 548 | # audio_clip。读取完成后立即关闭,避免早退或异常路径泄漏文件句柄。 |
| 549 | audio_duration = audio_clip.duration |
| 550 | finally: |
| 551 | close_clip(audio_clip) |
| 552 | logger.info(f"audio duration: {audio_duration} seconds") |
| 553 | logger.info(f"maximum clip duration: {max_clip_duration} seconds") |
| 554 | required_video_duration = _get_required_video_duration(audio_duration) |
| 555 | logger.info( |
| 556 | f"required video duration: {required_video_duration:.2f} seconds " |
| 557 | f"(audio duration + {_VIDEO_DURATION_SAFETY_MARGIN:.2f}s safety margin)" |
| 558 | ) |
| 559 | |
| 560 | # 兼容 API 直接调用时未传转场模式的情况,避免后续访问 .value 时崩溃。 |
| 561 | transition_value = getattr(video_transition_mode, "value", video_transition_mode) |
| 562 | output_dir = os.path.dirname(combined_video_path) |
| 563 | |
| 564 | aspect = VideoAspect(video_aspect) |
| 565 | video_width, video_height = aspect.to_resolution() |
| 566 | |
| 567 | processed_clips = [] |
| 568 | subclipped_items = [] |
| 569 | video_duration = 0 |
| 570 | for video_path in video_paths: |
| 571 | clip = _open_video_clip_quietly(video_path) |
| 572 | clip_duration = clip.duration |
| 573 | clip_w, clip_h = clip.size |
| 574 | close_clip(clip) |
| 575 | |
| 576 | start_time = 0 |
| 577 | |
| 578 | while start_time < clip_duration: |
| 579 | end_time = min(start_time + max_clip_duration, clip_duration) |
| 580 | |
| 581 | # 保留所有有效分段。 |
| 582 | # 这样既不会丢掉“整段视频本身就短于 max_clip_duration”的素材, |
| 583 | # 也不会吞掉长视频最后剩下的一小段尾部内容。 |
| 584 | if end_time > start_time: |
| 585 | subclipped_items.append( |
| 586 | SubClippedVideoClip( |
| 587 | file_path=video_path, |
| 588 | start_time=start_time, |
| 589 | end_time=end_time, |
| 590 | width=clip_w, |
| 591 | height=clip_h, |
| 592 | source_file_path=video_path, |
| 593 | ) |
| 594 | ) |
| 595 | |
| 596 | start_time = end_time |
| 597 | if video_concat_mode.value == VideoConcatMode.sequential.value: |
| 598 | break |
| 599 | |
| 600 | subclipped_items = _prioritize_unique_source_clips( |
| 601 | subclipped_items=subclipped_items, |
| 602 | concat_mode=video_concat_mode, |
| 603 | ) |
| 604 | |
| 605 | logger.debug(f"total subclipped items: {len(subclipped_items)}") |
| 606 | |
| 607 | # Add downloaded clips over and over until the duration of the audio (max_duration) has been reached |
| 608 | for i, subclipped_item in enumerate(subclipped_items): |
| 609 | if video_duration >= required_video_duration: |
| 610 | break |
| 611 | |
| 612 | logger.debug( |
| 613 | f"processing clip {i+1}: {subclipped_item.width}x{subclipped_item.height}, " |
| 614 | f"source: {os.path.basename(subclipped_item.source_file_path)}, " |
| 615 | f"current duration: {video_duration:.2f}s, " |
| 616 | f"remaining: {required_video_duration - video_duration:.2f}s" |
| 617 | ) |
| 618 | |
| 619 | try: |
| 620 | clip = _open_video_clip_quietly(subclipped_item.file_path).subclipped( |
| 621 | subclipped_item.start_time, subclipped_item.end_time |
| 622 | ) |
| 623 | clip_duration = clip.duration |
| 624 | # Not all videos are same size, so we need to resize them |
| 625 | clip_w, clip_h = clip.size |
| 626 | if clip_w != video_width or clip_h != video_height: |
| 627 | clip_ratio = clip.w / clip.h |
| 628 | video_ratio = video_width / video_height |
| 629 | logger.debug(f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}") |
| 630 | |
| 631 | if clip_ratio == video_ratio: |
| 632 | clip = clip.resized(new_size=(video_width, video_height)) |
| 633 | else: |
| 634 | if clip_ratio > video_ratio: |
| 635 | scale_factor = video_width / clip_w |
| 636 | else: |
| 637 | scale_factor = video_height / clip_h |
| 638 | |
| 639 | new_width = int(clip_w * scale_factor) |
| 640 | new_height = int(clip_h * scale_factor) |
| 641 | |
| 642 | background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(clip_duration) |
| 643 | clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center") |
| 644 | clip = CompositeVideoClip([background, clip_resized]) |
| 645 | |
| 646 | shuffle_side = random.choice(["left", "right", "top", "bottom"]) |
| 647 | if transition_value in (None, VideoTransitionMode.none.value): |
| 648 | clip = clip |
| 649 | elif transition_value == VideoTransitionMode.fade_in.value: |
| 650 | clip = video_effects.fadein_transition(clip, 1) |
| 651 | elif transition_value == VideoTransitionMode.fade_out.value: |
| 652 | clip = video_effects.fadeout_transition(clip, 1) |
| 653 | elif transition_value == VideoTransitionMode.slide_in.value: |
| 654 | clip = video_effects.slidein_transition(clip, 1, shuffle_side) |
| 655 | elif transition_value == VideoTransitionMode.slide_out.value: |
| 656 | clip = video_effects.slideout_transition(clip, 1, shuffle_side) |
| 657 | elif transition_value == VideoTransitionMode.shuffle.value: |
| 658 | transition_funcs = [ |
| 659 | lambda c: video_effects.fadein_transition(c, 1), |
| 660 | lambda c: video_effects.fadeout_transition(c, 1), |
| 661 | lambda c: video_effects.slidein_transition(c, 1, shuffle_side), |
| 662 | lambda c: video_effects.slideout_transition(c, 1, shuffle_side), |
| 663 | ] |
| 664 | shuffle_transition = random.choice(transition_funcs) |
| 665 | clip = shuffle_transition(clip) |
| 666 | |
| 667 | if clip.duration > max_clip_duration: |
| 668 | clip = clip.subclipped(0, max_clip_duration) |
| 669 | |
| 670 | # wirte clip to temp file |
| 671 | clip_file = f"{output_dir}/temp-clip-{i+1}.mp4" |
| 672 | _write_videofile_with_codec_fallback( |
| 673 | clip, |
| 674 | clip_file, |
| 675 | codec=_get_configured_video_codec(), |
| 676 | logger=None, |
| 677 | fps=fps, |
| 678 | ) |
| 679 | |
| 680 | # Store clip duration before closing |
| 681 | clip_duration_saved = clip.duration |
| 682 | close_clip(clip) |
| 683 | |
| 684 | processed_clips.append( |
| 685 | SubClippedVideoClip( |
| 686 | file_path=clip_file, |
| 687 | duration=clip_duration_saved, |
| 688 | width=clip_w, |
| 689 | height=clip_h, |
| 690 | source_file_path=subclipped_item.source_file_path, |
| 691 | ) |
| 692 | ) |
| 693 | video_duration += clip_duration_saved |
| 694 | |
| 695 | except Exception as e: |
| 696 | logger.error(f"failed to process clip: {str(e)}") |
| 697 | |
| 698 | # loop processed clips until the video duration covers the audio duration and the small safety margin. |
| 699 | if video_duration < required_video_duration: |
| 700 | logger.warning( |
| 701 | f"video duration ({video_duration:.2f}s) is shorter than required duration " |
| 702 | f"({required_video_duration:.2f}s), looping clips to match audio length." |
| 703 | ) |
| 704 | base_clips = processed_clips.copy() |
| 705 | for clip in itertools.cycle(base_clips): |
| 706 | if video_duration >= required_video_duration: |
| 707 | break |
| 708 | processed_clips.append(clip) |
| 709 | video_duration += clip.duration |
| 710 | logger.info( |
| 711 | f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, " |
| 712 | f"required duration: {required_video_duration:.2f}s, " |
| 713 | f"looped {len(processed_clips)-len(base_clips)} clips" |
| 714 | ) |
| 715 | |
| 716 | # merge video clips progressively, avoid loading all videos at once to avoid memory overflow |
| 717 | logger.info("starting clip merging process") |
| 718 | if not processed_clips: |
| 719 | logger.warning("no clips available for merging") |
| 720 | return combined_video_path |
| 721 | |
| 722 | # if there is only one clip, use it directly |
| 723 | if len(processed_clips) == 1: |
| 724 | logger.info("using single clip directly") |
| 725 | shutil.copy(processed_clips[0].file_path, combined_video_path) |
| 726 | delete_files([processed_clips[0].file_path]) |
| 727 | logger.info("video combining completed") |
| 728 | return combined_video_path |
| 729 | |
| 730 | clip_files = [clip.file_path for clip in processed_clips] |
| 731 | logger.info(f"concatenating {len(clip_files)} clips with ffmpeg") |
| 732 | concat_video_clips_with_ffmpeg( |
| 733 | clip_files=clip_files, |
| 734 | output_file=combined_video_path, |
| 735 | threads=threads, |
| 736 | output_dir=output_dir, |
| 737 | ) |
| 738 | |
| 739 | # clean temp files |
| 740 | delete_files(clip_files) |
| 741 | |
| 742 | logger.info("video combining completed") |
| 743 | return combined_video_path |
| 744 | |
| 745 | |
| 746 | def wrap_text(text, max_width, font="Arial", fontsize=60): |
| 747 | # 字幕换行必须在真正创建 TextClip 前完成,否则 MoviePy 只会按原始文本 |
| 748 | # 计算渲染区域。这里用 PIL 按当前字体和字号测量宽度,确保每一行都尽量 |
| 749 | # 控制在视频可用宽度内,避免大字号或中文长句直接溢出画面。 |
| 750 | font = ImageFont.truetype(font, fontsize) |
| 751 | max_width = int(max_width) |
| 752 | |
| 753 | def get_text_size(inner_text): |
| 754 | inner_text = inner_text.strip() |
| 755 | if not inner_text: |
| 756 | return 0, fontsize |
| 757 | left, top, right, bottom = font.getbbox(inner_text) |
| 758 | return right - left, bottom - top |
| 759 | |
| 760 | width, height = get_text_size(text) |
| 761 | if width <= max_width: |
| 762 | return text, height |
| 763 | |
| 764 | def split_long_token(token): |
| 765 | # 当一个 token 本身就超宽时(常见于中文无空格长句,或英文超长单词), |
| 766 | # 退化为字符级拆分。关键点是:检测到 candidate 超宽时,先提交上一个 |
| 767 | # 仍然合法的 current,再把当前字符放入下一行,不能把超宽字符塞回上一行。 |
| 768 | lines = [] |
| 769 | current = "" |
| 770 | for char in token: |
| 771 | candidate = f"{current}{char}" |
| 772 | candidate_width, _ = get_text_size(candidate) |
| 773 | if candidate_width <= max_width or not current: |
| 774 | current = candidate |
| 775 | continue |
| 776 | lines.append(current) |
| 777 | current = char |
| 778 | if current: |
| 779 | lines.append(current) |
| 780 | return lines |
| 781 | |
| 782 | lines = [] |
| 783 | current = "" |
| 784 | words = text.split(" ") |
| 785 | for word in words: |
| 786 | candidate = f"{current} {word}".strip() if current else word |
| 787 | candidate_width, _ = get_text_size(candidate) |
| 788 | if candidate_width <= max_width: |
| 789 | current = candidate |
| 790 | continue |
| 791 | |
| 792 | if current: |
| 793 | lines.append(current) |
| 794 | |
| 795 | word_width, _ = get_text_size(word) |
| 796 | if word_width <= max_width: |
| 797 | current = word |
| 798 | else: |
| 799 | lines.extend(split_long_token(word)) |
| 800 | current = "" |
| 801 | |
| 802 | if current: |
| 803 | lines.append(current) |
| 804 | |
| 805 | line_start_punctuation = ",。!?;:、,.!?;:)]})】》」』”’" |
| 806 | for index in range(1, len(lines)): |
| 807 | # 中文长句按字符拆分时,最后一个句号、逗号等闭合标点可能被单独 |
| 808 | # 放到下一行,导致字幕背景被异常撑高,视觉上像一个小点掉在正文 |
| 809 | # 下方。这里在不重新设计换行算法的前提下,把上一行最后一个字 |
| 810 | # 移到标点行前面,让标点跟随文字显示,兼容中英文常见闭合标点。 |
| 811 | if not lines[index] or lines[index][0] not in line_start_punctuation: |
| 812 | continue |
| 813 | if len(lines[index - 1]) <= 1: |
| 814 | continue |
| 815 | |
| 816 | candidate = f"{lines[index - 1][-1]}{lines[index]}" |
| 817 | candidate_width, _ = get_text_size(candidate) |
| 818 | if candidate_width <= max_width: |
| 819 | lines[index] = candidate |
| 820 | lines[index - 1] = lines[index - 1][:-1] |
| 821 | |
| 822 | result = "\n".join(line.strip() for line in lines if line.strip()).strip() |
| 823 | height = len(lines) * height |
| 824 | return result, height |
| 825 | |
| 826 | |
| 827 | def _hex_to_rgb(color: str) -> tuple[int, int, int]: |
| 828 | # 字幕背景色来自 API/WebUI 参数,可能为空或格式不规范。这里统一只接受 |
| 829 | # #RRGGBB 形式,非法值回退为黑色,避免 PIL 渲染阶段抛出异常中断任务。 |
| 830 | if isinstance(color, str) and color.startswith("#") and len(color) == 7: |
| 831 | try: |
| 832 | return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)) |
| 833 | except ValueError: |
| 834 | pass |
| 835 | return (0, 0, 0) |
| 836 | |
| 837 | |
| 838 | def _rounded_subtitle_background_clip( |
| 839 | width: int, |
| 840 | height: int, |
| 841 | color: str, |
| 842 | alpha: int = 140, |
| 843 | radius: int = 16, |
| 844 | ) -> ImageClip: |
| 845 | # 新字幕背景仅在用户显式开启时使用:通过 RGBA 图片绘制圆角半透明底板, |
| 846 | # 再交给 MoviePy 作为透明 ImageClip 参与合成。这样默认路径完全不变, |
| 847 | # 同时可以低成本试验更柔和的字幕视觉效果。 |
| 848 | rgb = _hex_to_rgb(color) |
| 849 | safe_alpha = max(0, min(255, int(alpha))) |
| 850 | img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) |
| 851 | draw = ImageDraw.Draw(img) |
| 852 | draw.rounded_rectangle( |
| 853 | [0, 0, max(0, width - 1), max(0, height - 1)], |
| 854 | radius=max(0, int(radius)), |
| 855 | fill=(rgb[0], rgb[1], rgb[2], safe_alpha), |
| 856 | ) |
| 857 | return ImageClip(np.array(img), transparent=True) |
| 858 | |
| 859 | |
| 860 | def _get_visible_center_position( |
| 861 | text_clip: TextClip, |
| 862 | container_width: int, |
| 863 | container_height: int, |
| 864 | ) -> tuple[int, int]: |
| 865 | """ |
| 866 | 按文字真实可见像素把 TextClip 放到背景容器中心。 |
| 867 | |
| 868 | MoviePy 的 TextClip 会按字体行高和 baseline 创建透明画布。很多字体的 |
| 869 | 可见字形并不在这个画布的几何中心,直接 `with_position("center")` |
| 870 | 会把整块透明画布居中,导致字幕看起来偏上或偏下。这里读取 TextClip |
| 871 | 的透明 mask,只根据实际有像素的 bbox 计算偏移,让用户看到的文字 |
| 872 | 在字幕背景里视觉居中。 |
| 873 | """ |
| 874 | x = int(round((container_width - text_clip.w) / 2)) |
| 875 | y = int(round((container_height - text_clip.h) / 2)) |
| 876 | |
| 877 | try: |
| 878 | if text_clip.mask is None: |
| 879 | return x, y |
| 880 | |
| 881 | mask_frame = text_clip.mask.get_frame(0) |
| 882 | ys, _ = np.where(mask_frame > 0.01) |
| 883 | if len(ys) == 0: |
| 884 | return x, y |
| 885 | |
| 886 | visible_top = int(ys.min()) |
| 887 | visible_bottom = int(ys.max()) |
| 888 | visible_height = visible_bottom - visible_top + 1 |
| 889 | y = int(round((container_height - visible_height) / 2 - visible_top)) |
| 890 | except Exception as exc: |
| 891 | logger.debug(f"failed to center subtitle text by visible mask: {str(exc)}") |
| 892 | |
| 893 | return x, y |
| 894 | |
| 895 | |
| 896 | def generate_video( |
| 897 | video_path: str, |
| 898 | audio_path: str, |
| 899 | subtitle_path: str, |
| 900 | output_file: str, |
| 901 | params: VideoParams, |
| 902 | ): |
| 903 | aspect = VideoAspect(params.video_aspect) |
| 904 | video_width, video_height = aspect.to_resolution() |
| 905 | |
| 906 | logger.info(f"generating video: {video_width} x {video_height}") |
| 907 | logger.info(f" ① video: {video_path}") |
| 908 | logger.info(f" ② audio: {audio_path}") |
| 909 | logger.info(f" ③ subtitle: {subtitle_path}") |
| 910 | logger.info(f" ④ output: {output_file}") |
| 911 | |
| 912 | # https://github.com/harry0703/MoneyPrinterTurbo/issues/217 |
| 913 | # PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'final-1.mp4.tempTEMP_MPY_wvf_snd.mp3' |
| 914 | # write into the same directory as the output file |
| 915 | output_dir = os.path.dirname(output_file) |
| 916 | |
| 917 | font_path = "" |
| 918 | if params.subtitle_enabled: |
| 919 | if not params.font_name: |
| 920 | params.font_name = "STHeitiMedium.ttc" |
| 921 | font_path = os.path.join(utils.font_dir(), params.font_name) |
| 922 | if os.name == "nt": |
| 923 | font_path = font_path.replace("\\", "/") |
| 924 | |
| 925 | logger.info(f" ⑤ font: {font_path}") |
| 926 | |
| 927 | def resolve_subtitle_background_color(): |
| 928 | # 兼容历史参数:API 里 `text_background_color` 既可能是布尔值, |
| 929 | # 也可能是实际颜色字符串。统一在这里归一化,避免把 True/False |
| 930 | # 直接传给 TextClip 后出现不可预期的渲染结果。 |
| 931 | if isinstance(params.text_background_color, bool): |
| 932 | return "#000000" if params.text_background_color else None |
| 933 | return params.text_background_color |
| 934 | |
| 935 | def create_text_clip(subtitle_item): |
| 936 | params.font_size = int(params.font_size) |
| 937 | params.stroke_width = int(params.stroke_width) |
| 938 | phrase = subtitle_item[1] |
| 939 | max_width = video_width * 0.9 |
| 940 | bg_color = resolve_subtitle_background_color() |
| 941 | rounded_bg_enabled = bool( |
| 942 | getattr(params, "rounded_subtitle_background", False) and bg_color |
| 943 | ) |
| 944 | has_subtitle_background = bool(bg_color) |
| 945 | pad_x = int(params.font_size * 0.6) if has_subtitle_background else 0 |
| 946 | # 字幕背景需要给文字左右留出明确内边距。先从可用宽度中扣除 |
| 947 | # padding 再换行,避免长英文或大字号刚好撑满 90% 视频宽度后, |
| 948 | # 文字贴到背景框边缘,看起来像被裁切。普通矩形背景和圆角背景 |
| 949 | # 都走这条逻辑;无背景字幕则保持原有最大宽度。 |
| 950 | text_max_width = max(1, int(max_width) - 2 * pad_x) |
| 951 | wrapped_txt, txt_height = wrap_text( |
| 952 | phrase, |
| 953 | max_width=text_max_width, |
| 954 | font=font_path, |
| 955 | fontsize=params.font_size, |
| 956 | ) |
| 957 | interline = int(params.font_size * 0.25) |
| 958 | line_count = wrapped_txt.count("\n") + 1 |
| 959 | vertical_padding = int(params.font_size * 0.35) |
| 960 | text_clip_margin_y = max( |
| 961 | int(params.font_size * 0.3), int(params.stroke_width * 2) |
| 962 | ) |
| 963 | # MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、 |
| 964 | # 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入 |
| 965 | # 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕 |
| 966 | # 背景框与文字本身都能完整渲染出来。 |
| 967 | clip_h = int(txt_height + vertical_padding + (interline * line_count)) |
| 968 | |
| 969 | if rounded_bg_enabled: |
| 970 | # 圆角背景需要贴合文字宽度,而不是沿用 90% 视频宽度。这里先用 |
| 971 | # PIL 测量最长一行文字,再加水平内边距,避免短字幕出现过宽底板。 |
| 972 | try: |
| 973 | font = ImageFont.truetype(font_path, params.font_size) |
| 974 | text_w = max( |
| 975 | int(font.getbbox(line)[2] - font.getbbox(line)[0]) |
| 976 | for line in wrapped_txt.split("\n") |
| 977 | ) |
| 978 | except Exception as exc: |
| 979 | logger.warning( |
| 980 | f"failed to measure subtitle text width, fallback to max width: {str(exc)}" |
| 981 | ) |
| 982 | text_w = int(max_width) |
| 983 | |
| 984 | box_w = max(1, min(int(max_width), text_w + 2 * pad_x)) |
| 985 | radius = max(8, int(params.font_size * 0.4)) |
| 986 | text_clip = TextClip( |
| 987 | text=wrapped_txt, |
| 988 | font=font_path, |
| 989 | font_size=params.font_size, |
| 990 | color=params.text_fore_color, |
| 991 | bg_color=None, |
| 992 | stroke_color=params.stroke_color, |
| 993 | stroke_width=params.stroke_width, |
| 994 | interline=interline, |
| 995 | size=(box_w, None), |
| 996 | text_align="center", |
| 997 | margin=(0, text_clip_margin_y), |
| 998 | ) |
| 999 | clip_h = max(clip_h, text_clip.h) |
| 1000 | bg_clip = _rounded_subtitle_background_clip( |
| 1001 | width=box_w, |
| 1002 | height=clip_h, |
| 1003 | color=bg_color, |
| 1004 | alpha=140, |
| 1005 | radius=radius, |
| 1006 | ) |
| 1007 | text_position = _get_visible_center_position(text_clip, box_w, clip_h) |
| 1008 | _clip = CompositeVideoClip( |
| 1009 | [bg_clip, text_clip.with_position(text_position)], |
| 1010 | size=(box_w, clip_h), |
| 1011 | ) |
| 1012 | elif bg_color: |
| 1013 | size = ( |
| 1014 | int(max_width), |
| 1015 | clip_h, |
| 1016 | ) |
| 1017 | text_clip = TextClip( |
| 1018 | text=wrapped_txt, |
| 1019 | font=font_path, |
| 1020 | font_size=params.font_size, |
| 1021 | color=params.text_fore_color, |
| 1022 | bg_color=None, |
| 1023 | stroke_color=params.stroke_color, |
| 1024 | stroke_width=params.stroke_width, |
| 1025 | interline=interline, |
| 1026 | size=(int(max_width), None), |
| 1027 | text_align="center", |
| 1028 | margin=(0, text_clip_margin_y), |
| 1029 | ) |
| 1030 | size = (size[0], max(size[1], text_clip.h)) |
| 1031 | bg_clip = _rounded_subtitle_background_clip( |
| 1032 | width=size[0], |
| 1033 | height=size[1], |
| 1034 | color=bg_color, |
| 1035 | alpha=255, |
| 1036 | radius=0, |
| 1037 | ) |
| 1038 | text_position = _get_visible_center_position(text_clip, size[0], size[1]) |
| 1039 | _clip = CompositeVideoClip( |
| 1040 | [bg_clip, text_clip.with_position(text_position)], |
| 1041 | size=size, |
| 1042 | ) |
| 1043 | else: |
| 1044 | size = ( |
| 1045 | int(max_width), |
| 1046 | clip_h, |
| 1047 | ) |
| 1048 | _clip = TextClip( |
| 1049 | text=wrapped_txt, |
| 1050 | font=font_path, |
| 1051 | font_size=params.font_size, |
| 1052 | color=params.text_fore_color, |
| 1053 | bg_color=None, |
| 1054 | stroke_color=params.stroke_color, |
| 1055 | stroke_width=params.stroke_width, |
| 1056 | interline=interline, |
| 1057 | size=size, |
| 1058 | text_align="center", |
| 1059 | ) |
| 1060 | duration = subtitle_item[0][1] - subtitle_item[0][0] |
| 1061 | _clip = _clip.with_start(subtitle_item[0][0]) |
| 1062 | _clip = _clip.with_end(subtitle_item[0][1]) |
| 1063 | _clip = _clip.with_duration(duration) |
| 1064 | if params.subtitle_position == "bottom": |
| 1065 | _clip = _clip.with_position(("center", video_height * 0.95 - _clip.h)) |
| 1066 | elif params.subtitle_position == "top": |
| 1067 | _clip = _clip.with_position(("center", video_height * 0.05)) |
| 1068 | elif params.subtitle_position == "custom": |
| 1069 | # Ensure the subtitle is fully within the screen bounds |
| 1070 | margin = 10 # Additional margin, in pixels |
| 1071 | max_y = video_height - _clip.h - margin |
| 1072 | min_y = margin |
| 1073 | custom_y = (video_height - _clip.h) * (params.custom_position / 100) |
| 1074 | custom_y = max( |
| 1075 | min_y, min(custom_y, max_y) |
| 1076 | ) # Constrain the y value within the valid range |
| 1077 | _clip = _clip.with_position(("center", custom_y)) |
| 1078 | else: # center |
| 1079 | _clip = _clip.with_position(("center", "center")) |
| 1080 | return _clip |
| 1081 | |
| 1082 | video_clip = _open_video_clip_quietly(video_path) |
| 1083 | audio_clip = AudioFileClip(audio_path).with_effects( |
| 1084 | [afx.MultiplyVolume(params.voice_volume)] |
| 1085 | ) |
| 1086 | |
| 1087 | def make_textclip(text): |
| 1088 | return TextClip( |
| 1089 | text=text, |
| 1090 | font=font_path, |
| 1091 | font_size=params.font_size, |
| 1092 | ) |
| 1093 | |
| 1094 | if subtitle_path and os.path.exists(subtitle_path): |
| 1095 | sub = SubtitlesClip( |
| 1096 | subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip |
| 1097 | ) |
| 1098 | text_clips = [] |
| 1099 | for item in sub.subtitles: |
| 1100 | clip = create_text_clip(subtitle_item=item) |
| 1101 | text_clips.append(clip) |
| 1102 | video_clip = CompositeVideoClip([video_clip, *text_clips]) |
| 1103 | |
| 1104 | bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file) |
| 1105 | if bgm_file: |
| 1106 | try: |
| 1107 | bgm_clip = AudioFileClip(bgm_file).with_effects( |
| 1108 | [ |
| 1109 | afx.MultiplyVolume(params.bgm_volume), |
| 1110 | afx.AudioFadeOut(3), |
| 1111 | afx.AudioLoop(duration=video_clip.duration), |
| 1112 | ] |
| 1113 | ) |
| 1114 | audio_clip = CompositeAudioClip([audio_clip, bgm_clip]) |
| 1115 | except Exception as e: |
| 1116 | logger.error(f"failed to add bgm: {str(e)}") |
| 1117 | |
| 1118 | video_clip = video_clip.with_audio(audio_clip) |
| 1119 | # 显式沿用输入音频的采样率;如果取不到,再回退到 MoviePy 默认的 44100Hz。 |
| 1120 | # 这样可以减少不同运行环境,尤其是 Docker 环境中再次重采样带来的音质波动。 |
| 1121 | output_audio_fps = int(getattr(audio_clip, "fps", 0) or 44100) |
| 1122 | _write_videofile_with_codec_fallback( |
| 1123 | video_clip, |
| 1124 | output_file=output_file, |
| 1125 | codec=_get_configured_video_codec(), |
| 1126 | audio_codec=audio_codec, |
| 1127 | audio_fps=output_audio_fps, |
| 1128 | audio_bitrate=audio_bitrate, |
| 1129 | temp_audiofile_path=_get_temp_audio_dir(output_dir), |
| 1130 | threads=params.n_threads or 2, |
| 1131 | logger=None, |
| 1132 | fps=fps, |
| 1133 | ) |
| 1134 | video_clip.close() |
| 1135 | del video_clip |
| 1136 | |
| 1137 | |
| 1138 | def preprocess_video(materials: List[MaterialInfo], clip_duration=4): |
| 1139 | # WebUI 在某些二次生成场景下可能传入空素材列表,这里直接返回空结果,避免抛出 NoneType 异常。 |
| 1140 | if not materials: |
| 1141 | return [] |
| 1142 | |
| 1143 | # 仅返回通过预处理校验的素材,避免低分辨率图片继续进入后续的视频合成流程。 |
| 1144 | valid_materials = [] |
| 1145 | local_videos_dir = utils.storage_dir("local_videos", create=True) |
| 1146 | |
| 1147 | for material in materials: |
| 1148 | if not material.url: |
| 1149 | continue |
| 1150 | |
| 1151 | try: |
| 1152 | material_source_path = file_security.resolve_path_within_directory( |
| 1153 | local_videos_dir, material.url |
| 1154 | ) |
| 1155 | except ValueError as exc: |
| 1156 | # local video_source 的素材路径来自 API 参数,必须限制在专用素材目录。 |
| 1157 | # 允许用户传文件名,也兼容历史返回的绝对路径,但不允许逃逸到系统 |
| 1158 | # 其他目录,避免任意文件读取或通过 MoviePy 探测本地敏感文件。 |
| 1159 | logger.warning( |
| 1160 | f"skip unsafe local material: {material.url}, " |
| 1161 | f"local_videos_dir: {local_videos_dir}, error: {str(exc)}" |
| 1162 | ) |
| 1163 | continue |
| 1164 | |
| 1165 | ext = utils.parse_extension(material_source_path) |
| 1166 | try: |
| 1167 | # 图片素材直接按图片方式读取,避免先走 VideoFileClip 误判后触发不稳定的回退分支。 |
| 1168 | if ext in const.FILE_TYPE_IMAGES: |
| 1169 | clip, material_source_path = _open_image_clip_with_fallback( |
| 1170 | material_source_path |
| 1171 | ) |
| 1172 | else: |
| 1173 | clip = _open_video_clip_quietly(material_source_path) |
| 1174 | except Exception: |
| 1175 | # 非标准扩展名或探测失败时再回退到图片模式,兼容历史上直接传本地图片路径的情况。 |
| 1176 | try: |
| 1177 | clip, material_source_path = _open_image_clip_with_fallback( |
| 1178 | material_source_path |
| 1179 | ) |
| 1180 | except Exception as exc: |
| 1181 | logger.warning( |
| 1182 | f"skip unreadable local material: {material.url}, error: {str(exc)}" |
| 1183 | ) |
| 1184 | continue |
| 1185 | try: |
| 1186 | width = clip.size[0] |
| 1187 | height = clip.size[1] |
| 1188 | if width < 480 or height < 480: |
| 1189 | logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required") |
| 1190 | # 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。 |
| 1191 | close_clip(clip) |
| 1192 | continue |
| 1193 | |
| 1194 | if ext in const.FILE_TYPE_IMAGES: |
| 1195 | logger.info(f"processing image: {material_source_path}") |
| 1196 | # 探测尺寸时已经打开过一次素材,这里先释放探测句柄,再重新创建用于导出的图片 clip。 |
| 1197 | close_clip(clip) |
| 1198 | # Create an image clip and set its duration to 3 seconds |
| 1199 | clip = ( |
| 1200 | ImageClip(material_source_path) |
| 1201 | .with_duration(clip_duration) |
| 1202 | .with_position("center") |
| 1203 | ) |
| 1204 | # Apply a zoom effect using the resize method. |
| 1205 | # A lambda function is used to make the zoom effect dynamic over time. |
| 1206 | # The zoom effect starts from the original size and gradually scales up to 120%. |
| 1207 | # t represents the current time, and clip.duration is the total duration of the clip (3 seconds). |
| 1208 | # Note: 1 represents 100% size, so 1.2 represents 120% size. |
| 1209 | zoom_clip = clip.resized( |
| 1210 | lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration) |
| 1211 | ) |
| 1212 | |
| 1213 | # Optionally, create a composite video clip containing the zoomed clip. |
| 1214 | # This is useful when you want to add other elements to the video. |
| 1215 | final_clip = CompositeVideoClip([zoom_clip]) |
| 1216 | |
| 1217 | # Output the video to a file. |
| 1218 | video_file = f"{material_source_path}.mp4" |
| 1219 | final_clip.write_videofile(video_file, fps=30, logger=None) |
| 1220 | close_clip(clip) |
| 1221 | close_clip(final_clip) |
| 1222 | material.url = video_file |
| 1223 | logger.success(f"image processed: {video_file}") |
| 1224 | else: |
| 1225 | # 普通视频素材只需要读取尺寸做校验,校验完成后立即释放句柄即可。 |
| 1226 | close_clip(clip) |
| 1227 | # Update url to the resolved absolute path so that downstream |
| 1228 | # stages (combine_videos) can open the file without re-resolving. |
| 1229 | material.url = material_source_path |
| 1230 | except Exception: |
| 1231 | close_clip(clip) |
| 1232 | raise |
| 1233 | |
| 1234 | valid_materials.append(material) |
| 1235 | |
| 1236 | return valid_materials |
| 1237 |