返回 MoneyPrinterTurbo
video.py
根目录 / app / services / video.py
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
1237 lines PYTHON