Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

1323 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import glob
import itertools
import io
import os
import random
import gc
import subprocess
import sys
import tempfile
import unicodedata
from contextlib import redirect_stdout
from functools import lru_cache
from typing import List
from loguru import logger
import numpy as np
from moviepy import (
AudioFileClip,
ColorClip,
CompositeAudioClip,
CompositeVideoClip,
ImageClip,
TextClip,
VideoFileClip,
afx,
)
from moviepy.video.tools.subtitles import SubtitlesClip
from PIL import Image, ImageDraw, ImageFont
from app.config import config
from app.models import const
from app.models.schema import (
MaterialInfo,
VideoAspect,
VideoConcatMode,
VideoParams,
VideoTransitionMode,
)
from app.services.utils import video_effects
from app.utils import file_security, utils
class SubClippedVideoClip:
def __init__(
self,
file_path,
start_time=None,
end_time=None,
width=None,
height=None,
duration=None,
source_file_path=None,
):
self.file_path = file_path
self.start_time = start_time
self.end_time = end_time
self.width = width
self.height = height
self.source_file_path = source_file_path or file_path
if duration is None:
self.duration = end_time - start_time
else:
self.duration = duration
def __str__(self):
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})"
audio_codec = "aac"
# Docker 里的 ffmpeg/AAC 组合在默认配置下更容易出现音频质量波动,
# 这里显式抬高音频码率,避免成片阶段因为默认值过低而引入明显失真。
audio_bitrate = "192k"
fps = 30
# FFmpeg 按帧率拼接/转码时,最终时长可能比 MoviePy 读到的理论时长短几十毫秒。
# 这里给视频素材多留一个很小的安全余量,避免音频末尾因为帧舍入出现黑屏、
# 卡顿或最后一小段旁白没有画面的情况。
_VIDEO_DURATION_SAFETY_MARGIN = 0.1
_MIN_MATERIAL_DIMENSION = 480
# 消息类应用和部分编码器会把画面尺寸向下取整,例如 WhatsApp 会把 9:16 的
# 素材压成 478x850,比 480 少两个像素。直接按 480 硬卡会让这类素材全部被
# 丢弃,最终以 "no valid materials found" 整体失败。这里留一个很小的容差,
# 既能放行仅仅因为取整而略低于阈值的素材,也仍然能挡住真正的低清素材。
_MIN_DIMENSION_TOLERANCE = 10
_BGM_EXTENSIONS = (".mp3",)
_DEFAULT_VIDEO_CODEC = "libx264"
_SUPPORTED_VIDEO_CODECS = (
"libx264",
"h264_nvenc",
"h264_amf",
"h264_qsv",
"h264_mf",
"h264_videotoolbox",
)
_runtime_disabled_video_codecs = set()
def _get_required_video_duration(audio_duration: float) -> float:
"""
返回视频素材拼接的目标时长。
使用场景:合成视频时需要素材时长覆盖旁白音频。只做到“刚好等于”
音频时长时,FFmpeg 可能因为帧率舍入让最终视频略短,因此统一加一个
轻量余量。函数独立出来,便于测试和后续按实际反馈调整余量大小。
"""
return max(0.0, float(audio_duration) + _VIDEO_DURATION_SAFETY_MARGIN)
def is_material_resolution_acceptable(width: int, height: int) -> bool:
"""
判断素材分辨率是否足够用于合成。
标称最小值是 480x480,但允许比它低 `_MIN_DIMENSION_TOLERANCE` 个像素,
以兼容编码器/消息应用向下取整导致的尺寸(例如 WhatsApp 的 478x850)。
"""
min_dimension = _MIN_MATERIAL_DIMENSION - _MIN_DIMENSION_TOLERANCE
return width >= min_dimension and height >= min_dimension
def _prioritize_unique_source_clips(
subclipped_items: List[SubClippedVideoClip],
concat_mode: VideoConcatMode,
) -> List[SubClippedVideoClip]:
"""
优先让每个源素材只出现一次,降低成片里同一素材反复出现的概率。
线上素材经常会遇到“一个长视频被切成多个短片段”的情况。旧逻辑在
random 模式下直接打乱所有短片段,导致同一个源视频的多个切片可能
分布在开头和中间,用户会感知为素材重复。本函数只调整片段顺序:
先放每个源文件里最长的一个片段,剩余片段作为兜底;当素材总时长不足时,
仍然允许后续片段补齐音频长度,避免破坏视频生成成功率。优先选择最长
片段是为了避免随机选中视频尾部的零碎短片段,导致明明有足够素材却过早复用。
"""
if not subclipped_items:
return []
concat_mode_value = getattr(concat_mode, "value", concat_mode)
if concat_mode_value != VideoConcatMode.random.value:
return subclipped_items
grouped_items: dict[str, list[SubClippedVideoClip]] = {}
for item in subclipped_items:
grouped_items.setdefault(item.source_file_path, []).append(item)
primary_items = []
overflow_items = []
for items in grouped_items.values():
primary_item = max(items, key=lambda item: item.duration)
primary_items.append(primary_item)
overflow_items.extend(item for item in items if item is not primary_item)
random.shuffle(primary_items)
random.shuffle(overflow_items)
logger.info(
"prioritized unique video materials, "
f"sources: {len(grouped_items)}, "
f"primary clips: {len(primary_items)}, "
f"fallback clips: {len(overflow_items)}"
)
return primary_items + overflow_items
def get_ffmpeg_binary():
"""
兼容历史上直接从 video 服务读取 FFmpeg 路径的调用方。
真正的解析逻辑已经抽到 `app.utils.utils.get_ffmpeg_binary()`,视频、语音
和后续新增链路都应复用同一套优先级;这里保留薄包装,避免外部脚本或
旧测试直接导入 `app.services.video.get_ffmpeg_binary` 时出现 AttributeError。
"""
return utils.get_ffmpeg_binary()
def _get_configured_video_codec() -> str:
"""
读取用户配置的视频编码器。
该配置面向高级用户,用于尝试启用 NVENC/AMF/QSV/VideoToolbox 等硬件
编码。这里刻意只允许固定白名单,避免开放任意 FFmpeg 参数后,用户填错
参数导致输出格式不可控,甚至让生成任务在后续阶段才失败。
"""
configured_codec = str(
config.app.get("video_codec", _DEFAULT_VIDEO_CODEC) or _DEFAULT_VIDEO_CODEC
).strip()
if configured_codec not in _SUPPORTED_VIDEO_CODECS:
logger.warning(
f"unsupported video codec configured: {configured_codec}, "
f"fallback to {_DEFAULT_VIDEO_CODEC}"
)
return _DEFAULT_VIDEO_CODEC
return configured_codec
@lru_cache(maxsize=16)
def _ffmpeg_encoder_exists(ffmpeg_binary: str, codec: str) -> bool:
"""
检查当前 FFmpeg 是否声明支持指定编码器。
这只能证明 FFmpeg 编译时包含该 encoder,不能证明当前机器硬件和驱动
一定可用。因此实际编码失败时仍会再回退到 libx264。
"""
try:
result = subprocess.run(
[ffmpeg_binary, "-hide_banner", "-encoders"],
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning(
"failed to inspect ffmpeg encoders, "
f"fallback to {_DEFAULT_VIDEO_CODEC}: {str(exc)}"
)
return False
if result.returncode != 0:
logger.warning(
"failed to inspect ffmpeg encoders, "
f"fallback to {_DEFAULT_VIDEO_CODEC}: {(result.stderr or result.stdout or '').strip()}"
)
return False
return codec in result.stdout
def _get_effective_video_codec(preferred_codec: str | None = None) -> str:
"""
返回本次实际使用的视频编码器。
用户选择硬件编码器时,先做 FFmpeg encoder 列表检测;如果本进程里已经
实际编码失败过,也直接回退,避免一个任务里每个片段都重复失败。
"""
selected_codec = preferred_codec or _get_configured_video_codec()
if selected_codec == _DEFAULT_VIDEO_CODEC:
return _DEFAULT_VIDEO_CODEC
if selected_codec in _runtime_disabled_video_codecs:
logger.warning(
f"video codec {selected_codec} was disabled after a runtime failure, "
f"fallback to {_DEFAULT_VIDEO_CODEC}"
)
return _DEFAULT_VIDEO_CODEC
ffmpeg_binary = utils.get_ffmpeg_binary()
if not _ffmpeg_encoder_exists(ffmpeg_binary, selected_codec):
logger.warning(
f"ffmpeg encoder {selected_codec} is not available, "
f"fallback to {_DEFAULT_VIDEO_CODEC}"
)
return _DEFAULT_VIDEO_CODEC
return selected_codec
def _disable_runtime_video_codec(codec: str, reason: str):
if codec == _DEFAULT_VIDEO_CODEC:
return
_runtime_disabled_video_codecs.add(codec)
logger.warning(
f"video codec {codec} failed, fallback to {_DEFAULT_VIDEO_CODEC}. "
f"reason: {reason}"
)
def _get_temp_audio_dir(output_dir: str) -> str:
"""
Return the directory to use for MoviePy's temporary audio file.
On Windows, Windows Defender can lock files written to the task output
directory while scanning them, causing MoviePy to fail with a
PermissionError (WinError 32) on the TEMP_MPY_wvf_snd temp file and
leaving the final MP4 at 0 bytes. Using the system temp directory
sidesteps the scan without changing behaviour on other platforms.
On Linux/macOS/Docker the output directory is returned unchanged so
existing behaviour is preserved.
"""
if sys.platform == "win32":
return tempfile.gettempdir()
return output_dir
def _fallback_write_videofile(clip, output_file: str, failed_codec: str, reason: str, **kwargs):
"""
硬件编码失败后用 libx264 重试,只有重试成功才禁用该硬件编码器。
Windows 上 FFmpeg 失败原因比较复杂:可能是显卡/驱动不支持,也可能是输出
文件被占用、目录权限、杀软拦截等通用 IO 问题。只有 libx264 能成功写出时,
才能判断原始失败大概率来自硬件编码器本身,避免误伤后续任务。
"""
clip.write_videofile(output_file, codec=_DEFAULT_VIDEO_CODEC, **kwargs)
_disable_runtime_video_codec(failed_codec, reason)
return _DEFAULT_VIDEO_CODEC
def _write_videofile_with_codec_fallback(clip, output_file: str, codec: str, **kwargs):
"""
使用指定编码器写出视频,失败时自动用 libx264 重试一次。
硬件编码器是否可用不仅取决于 FFmpeg,还取决于显卡、驱动和当前运行环境。
生成任务不能因为高级编码器不可用而整体失败,所以这里把回退集中处理。
"""
effective_codec = _get_effective_video_codec(codec)
try:
clip.write_videofile(output_file, codec=effective_codec, **kwargs)
return effective_codec
except Exception as exc:
if effective_codec == _DEFAULT_VIDEO_CODEC:
raise
return _fallback_write_videofile(
clip,
output_file,
failed_codec=effective_codec,
reason=str(exc),
**kwargs,
)
def _escape_ffmpeg_concat_path(file_path: str) -> str:
# concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。
return file_path.replace("'", "'\\''")
def _format_ffmpeg_concat_path(file_path: str) -> str:
"""
生成 concat demuxer 文件列表中的路径。
FFmpeg 官方文档要求 concat list 中的特殊字符和空格需要转义;Windows
绝对路径里的反斜杠也容易被解析成转义字符。这里统一转成正斜杠形式,
让 `C:\\Users\\...` 变成 `C:/Users/...`,再处理单引号,兼容 macOS/Linux。
"""
absolute_path = os.path.abspath(file_path)
return _escape_ffmpeg_concat_path(absolute_path.replace("\\", "/"))
def concat_video_clips_with_ffmpeg(
clip_files: List[str],
output_file: str,
threads: int,
output_dir: str,
max_duration: float | None = None,
):
concat_list_file = os.path.join(output_dir, "ffmpeg-concat-list.txt")
with open(concat_list_file, "w", encoding="utf-8") as fp:
for clip_file in clip_files:
fp.write(f"file '{_format_ffmpeg_concat_path(clip_file)}'\n")
def build_command(codec: str) -> list[str]:
command = [
utils.get_ffmpeg_binary(),
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_list_file,
"-c:v",
codec,
"-threads",
str(threads or 2),
"-pix_fmt",
"yuv420p",
]
if max_duration is not None and max_duration > 0:
command.extend(["-t", f"{max_duration:.3f}"])
command.append(output_file)
return command
def run_concat(codec: str):
command = build_command(codec)
# 使用 ffmpeg 只做一次串联与编码,避免 MoviePy 逐段合并时反复重编码,
# 从而降低画质劣化与颜色偏移风险。
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
error_message = (result.stderr or result.stdout or "").strip()
raise RuntimeError(error_message or "ffmpeg concat failed")
return codec
try:
effective_codec = _get_effective_video_codec()
try:
return run_concat(effective_codec)
except Exception as exc:
if effective_codec == _DEFAULT_VIDEO_CODEC:
raise
result_codec = run_concat(_DEFAULT_VIDEO_CODEC)
_disable_runtime_video_codec(effective_codec, str(exc))
return result_codec
finally:
delete_files(concat_list_file)
def _sanitize_image_file(image_path: str) -> str:
# 某些本地图片虽然能被 Pillow 打开,但会因为损坏的 EXIF/eXIf 元数据导致
# ImageClip 在解析阶段直接抛异常。这里重新导出一份“干净图片”,把坏元数据剥离掉。
image_root, _ = os.path.splitext(image_path)
sanitized_path = f"{image_root}.sanitized.png"
with Image.open(image_path) as image:
image.load()
# 统一导出为 PNG,避免 JPEG/PNG 不同元数据路径继续把坏块带过去。
cleaned_image = Image.new(image.mode, image.size)
cleaned_image.putdata(list(image.getdata()))
cleaned_image.save(sanitized_path)
return sanitized_path
def _open_image_clip_with_fallback(image_path: str):
# 优先直接打开原始图片;如果因为损坏元数据失败,再尝试生成无元数据副本。
try:
return ImageClip(image_path), image_path
except Exception as exc:
logger.warning(
f"failed to open image directly, trying sanitized copy: {image_path}, error: {str(exc)}"
)
sanitized_path = _sanitize_image_file(image_path)
return ImageClip(sanitized_path), sanitized_path
def _open_video_clip_quietly(video_path: str, audio: bool = False) -> VideoFileClip:
"""
安静地打开视频文件,避免 MoviePy 2.1.x 把 ffmpeg 探测信息直接打印到 stdout。
背景:
当前依赖版本的 `FFMPEG_VideoReader` 内部存在 `print(self.infos)` 和
`print(ffmpeg command)`,读取无音轨的中间视频时会输出
`audio_found: False`。这只是输入素材 metadata,不代表最终成片没有音频,
但会误导 WebUI/终端用户以为生成失败。
实现:
1. 只在打开 VideoFileClip 的短窗口内重定向 stdout
2. 默认 `audio=False`,因为项目视频素材阶段不需要保留素材原声,
最终音频会在 `generate_video()` 阶段统一挂载;
3. 如果依赖库确实输出了内容,降级为 debug 日志,便于必要时排查。
"""
captured_stdout = io.StringIO()
with redirect_stdout(captured_stdout):
clip = VideoFileClip(video_path, audio=audio)
moviepy_stdout = captured_stdout.getvalue().strip()
if moviepy_stdout:
logger.debug(
"suppressed MoviePy video reader stdout for "
f"{video_path}, chars: {len(moviepy_stdout)}"
)
return clip
def close_clip(clip):
if clip is None:
return
try:
# close main resources
if hasattr(clip, 'reader') and clip.reader is not None:
clip.reader.close()
# close audio resources
if hasattr(clip, 'audio') and clip.audio is not None:
if hasattr(clip.audio, 'reader') and clip.audio.reader is not None:
clip.audio.reader.close()
del clip.audio
# close mask resources
if hasattr(clip, 'mask') and clip.mask is not None:
if hasattr(clip.mask, 'reader') and clip.mask.reader is not None:
clip.mask.reader.close()
del clip.mask
# handle child clips in composite clips
if hasattr(clip, 'clips') and clip.clips:
for child_clip in clip.clips:
if child_clip is not clip: # avoid possible circular references
close_clip(child_clip)
# clear clip list
if hasattr(clip, 'clips'):
clip.clips = []
except Exception as e:
logger.error(f"failed to close clip: {str(e)}")
del clip
gc.collect()
def delete_files(files: List[str] | str):
if isinstance(files, str):
files = [files]
for file in files:
try:
os.remove(file)
except Exception as e:
logger.debug(f"failed to delete file {file}: {str(e)}")
def _resolve_bgm_file_path(song_dir: str, bgm_file: str) -> str:
# 背景音乐只允许读取 resource/songs 目录内的文件,避免用户输入任意路径后
# 被 MoviePy 打开。这里兼容两种常见输入:
# 1. output000.mp3:来自 BGM 列表或用户只填写文件名
# 2. ./resource/songs/output000.mp3:用户按项目目录结构填写的相对路径
# 两种写法最终都会再次通过 resource/songs 白名单校验,不能绕过目录限制。
try:
return file_security.resolve_path_within_directory(song_dir, bgm_file)
except ValueError as song_dir_exc:
if os.path.isabs(bgm_file):
raise song_dir_exc
project_relative_file = os.path.join(utils.root_dir(), bgm_file)
try:
return file_security.resolve_path_within_directory(
song_dir, project_relative_file
)
except ValueError as root_dir_exc:
raise ValueError(str(root_dir_exc)) from song_dir_exc
def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""):
if not bgm_type:
return ""
if bgm_file:
song_dir = utils.song_dir()
try:
resolved_bgm_file = _resolve_bgm_file_path(song_dir, bgm_file)
except ValueError as exc:
# API 请求里的 bgm_file 来自用户输入,不能直接把任意绝对路径交给
# MoviePy 打开。这里强制限制到 resource/songs 目录,阻止读取
# /etc/passwd、配置文件、密钥等非背景音乐文件。
logger.warning(
f"reject unsafe bgm file: {bgm_file}, song_dir: {song_dir}, error: {str(exc)}"
)
return ""
if not resolved_bgm_file.lower().endswith(_BGM_EXTENSIONS):
logger.warning(f"reject unsupported bgm file extension: {resolved_bgm_file}")
return ""
return resolved_bgm_file
if bgm_type == "random":
suffix = "*.mp3"
song_dir = utils.song_dir()
files = glob.glob(os.path.join(song_dir, suffix))
# 当背景音乐目录为空时,直接回退为“不使用 BGM”,避免 random.choice([]) 抛异常。
if not files:
logger.warning(f"no bgm files found in song directory: {song_dir}")
return ""
return random.choice(files)
return ""
def combine_videos(
combined_video_path: str,
video_paths: List[str],
audio_file: str,
video_aspect: VideoAspect = VideoAspect.portrait,
video_concat_mode: VideoConcatMode = VideoConcatMode.random,
video_transition_mode: VideoTransitionMode = None,
max_clip_duration: int = 5,
threads: int = 2,
) -> str:
audio_clip = AudioFileClip(audio_file)
try:
# 这里只需要读取旁白音频时长来决定素材视频拼接长度;后续不会再使用
# audio_clip。读取完成后立即关闭,避免早退或异常路径泄漏文件句柄。
audio_duration = audio_clip.duration
finally:
close_clip(audio_clip)
logger.info(f"audio duration: {audio_duration} seconds")
logger.info(f"maximum clip duration: {max_clip_duration} seconds")
required_video_duration = _get_required_video_duration(audio_duration)
logger.info(
f"required video duration: {required_video_duration:.2f} seconds "
f"(audio duration + {_VIDEO_DURATION_SAFETY_MARGIN:.2f}s safety margin)"
)
# 兼容 API 直接调用时未传转场模式的情况,避免后续访问 .value 时崩溃。
transition_value = getattr(video_transition_mode, "value", video_transition_mode)
output_dir = os.path.dirname(combined_video_path)
aspect = VideoAspect(video_aspect)
video_width, video_height = aspect.to_resolution()
processed_clips = []
subclipped_items = []
video_duration = 0
for video_path in video_paths:
clip = _open_video_clip_quietly(video_path)
clip_duration = clip.duration
clip_w, clip_h = clip.size
close_clip(clip)
start_time = 0
while start_time < clip_duration:
end_time = min(start_time + max_clip_duration, clip_duration)
# 保留所有有效分段。
# 这样既不会丢掉“整段视频本身就短于 max_clip_duration”的素材,
# 也不会吞掉长视频最后剩下的一小段尾部内容。
if end_time > start_time:
subclipped_items.append(
SubClippedVideoClip(
file_path=video_path,
start_time=start_time,
end_time=end_time,
width=clip_w,
height=clip_h,
source_file_path=video_path,
)
)
start_time = end_time
if video_concat_mode.value == VideoConcatMode.sequential.value:
break
subclipped_items = _prioritize_unique_source_clips(
subclipped_items=subclipped_items,
concat_mode=video_concat_mode,
)
logger.debug(f"total subclipped items: {len(subclipped_items)}")
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
for i, subclipped_item in enumerate(subclipped_items):
if video_duration >= required_video_duration:
break
logger.debug(
f"processing clip {i+1}: {subclipped_item.width}x{subclipped_item.height}, "
f"source: {os.path.basename(subclipped_item.source_file_path)}, "
f"current duration: {video_duration:.2f}s, "
f"remaining: {required_video_duration - video_duration:.2f}s"
)
try:
clip = _open_video_clip_quietly(subclipped_item.file_path).subclipped(
subclipped_item.start_time, subclipped_item.end_time
)
clip_duration = clip.duration
# Not all videos are same size, so we need to resize them
clip_w, clip_h = clip.size
if clip_w != video_width or clip_h != video_height:
clip_ratio = clip.w / clip.h
video_ratio = video_width / video_height
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}")
if clip_ratio == video_ratio:
clip = clip.resized(new_size=(video_width, video_height))
else:
if clip_ratio > video_ratio:
scale_factor = video_width / clip_w
else:
scale_factor = video_height / clip_h
new_width = int(clip_w * scale_factor)
new_height = int(clip_h * scale_factor)
background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(clip_duration)
clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center")
clip = CompositeVideoClip([background, clip_resized])
shuffle_side = random.choice(["left", "right", "top", "bottom"])
if transition_value in (None, VideoTransitionMode.none.value):
clip = clip
elif transition_value == VideoTransitionMode.fade_in.value:
clip = video_effects.fadein_transition(clip, 1)
elif transition_value == VideoTransitionMode.fade_out.value:
clip = video_effects.fadeout_transition(clip, 1)
elif transition_value == VideoTransitionMode.slide_in.value:
clip = video_effects.slidein_transition(clip, 1, shuffle_side)
elif transition_value == VideoTransitionMode.slide_out.value:
clip = video_effects.slideout_transition(clip, 1, shuffle_side)
elif transition_value == VideoTransitionMode.zoom_in.value:
clip = video_effects.zoomin_transition(clip, 1)
elif transition_value == VideoTransitionMode.zoom_out.value:
clip = video_effects.zoomout_transition(clip, 1)
elif transition_value == VideoTransitionMode.shuffle.value:
transition_funcs = [
lambda c: video_effects.fadein_transition(c, 1),
lambda c: video_effects.fadeout_transition(c, 1),
lambda c: video_effects.slidein_transition(c, 1, shuffle_side),
lambda c: video_effects.slideout_transition(c, 1, shuffle_side),
lambda c: video_effects.zoomin_transition(c, 1),
lambda c: video_effects.zoomout_transition(c, 1),
]
shuffle_transition = random.choice(transition_funcs)
clip = shuffle_transition(clip)
if clip.duration > max_clip_duration:
clip = clip.subclipped(0, max_clip_duration)
# wirte clip to temp file
clip_file = f"{output_dir}/temp-clip-{i+1}.mp4"
_write_videofile_with_codec_fallback(
clip,
clip_file,
codec=_get_configured_video_codec(),
logger=None,
fps=fps,
)
# Store clip duration before closing
clip_duration_saved = clip.duration
close_clip(clip)
processed_clips.append(
SubClippedVideoClip(
file_path=clip_file,
duration=clip_duration_saved,
width=clip_w,
height=clip_h,
source_file_path=subclipped_item.source_file_path,
)
)
video_duration += clip_duration_saved
except Exception as e:
logger.error(f"failed to process clip: {str(e)}")
# loop processed clips until the video duration covers the audio duration and the small safety margin.
if video_duration < required_video_duration:
logger.warning(
f"video duration ({video_duration:.2f}s) is shorter than required duration "
f"({required_video_duration:.2f}s), looping clips to match audio length."
)
base_clips = processed_clips.copy()
for clip in itertools.cycle(base_clips):
if video_duration >= required_video_duration:
break
processed_clips.append(clip)
video_duration += clip.duration
logger.info(
f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, "
f"required duration: {required_video_duration:.2f}s, "
f"looped {len(processed_clips)-len(base_clips)} clips"
)
# merge video clips progressively, avoid loading all videos at once to avoid memory overflow
logger.info("starting clip merging process")
if not processed_clips:
logger.warning("no clips available for merging")
return combined_video_path
clip_files = [clip.file_path for clip in processed_clips]
logger.info(f"concatenating {len(clip_files)} clips with ffmpeg")
concat_video_clips_with_ffmpeg(
clip_files=clip_files,
output_file=combined_video_path,
threads=threads,
output_dir=output_dir,
max_duration=audio_duration,
)
# clean temp files
delete_files(clip_files)
logger.info("video combining completed")
return combined_video_path
def wrap_text(text, max_width, font="Arial", fontsize=60):
# 字幕换行必须在真正创建 TextClip 前完成,否则 MoviePy 只会按原始文本
# 计算渲染区域。这里用 PIL 按当前字体和字号测量宽度,确保每一行都尽量
# 控制在视频可用宽度内,避免大字号或中文长句直接溢出画面。
font = ImageFont.truetype(font, fontsize)
max_width = int(max_width)
def get_text_size(inner_text):
inner_text = inner_text.strip()
if not inner_text:
return 0, fontsize
left, top, right, bottom = font.getbbox(inner_text)
return right - left, bottom - top
width, height = get_text_size(text)
if width <= max_width:
return text, height
def split_long_token(token):
# 当一个 token 本身就超宽时(常见于中文无空格长句,或英文超长单词),
# 退化为字符级拆分。关键点是:检测到 candidate 超宽时,先提交上一个
# 仍然合法的 current,再把当前字符放入下一行,不能把超宽字符塞回上一行。
lines = []
current = ""
for char in token:
candidate = f"{current}{char}"
candidate_width, _ = get_text_size(candidate)
if candidate_width <= max_width or not current:
current = candidate
continue
lines.append(current)
current = char
if current:
lines.append(current)
return lines
lines = []
current = ""
words = text.split(" ")
for word in words:
candidate = f"{current} {word}".strip() if current else word
candidate_width, _ = get_text_size(candidate)
if candidate_width <= max_width:
current = candidate
continue
if current:
lines.append(current)
word_width, _ = get_text_size(word)
if word_width <= max_width:
current = word
else:
lines.extend(split_long_token(word))
current = ""
if current:
lines.append(current)
line_start_punctuation = ",。!?;:、,.!?;:)]})】》」』”’"
for index in range(1, len(lines)):
# 中文长句按字符拆分时,最后一个句号、逗号等闭合标点可能被单独
# 放到下一行,导致字幕背景被异常撑高,视觉上像一个小点掉在正文
# 下方。这里在不重新设计换行算法的前提下,把上一行最后一个字
# 移到标点行前面,让标点跟随文字显示,兼容中英文常见闭合标点。
if not lines[index] or lines[index][0] not in line_start_punctuation:
continue
if len(lines[index - 1]) <= 1:
continue
candidate = f"{lines[index - 1][-1]}{lines[index]}"
candidate_width, _ = get_text_size(candidate)
if candidate_width <= max_width:
lines[index] = candidate
lines[index - 1] = lines[index - 1][:-1]
result = "\n".join(line.strip() for line in lines if line.strip()).strip()
height = len(lines) * height
return result, height
def _hex_to_rgb(color: str) -> tuple[int, int, int]:
# 字幕背景色来自 API/WebUI 参数,可能为空或格式不规范。这里统一只接受
# #RRGGBB 形式,非法值回退为黑色,避免 PIL 渲染阶段抛出异常中断任务。
if isinstance(color, str) and color.startswith("#") and len(color) == 7:
try:
return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))
except ValueError:
pass
return (0, 0, 0)
def _rounded_subtitle_background_clip(
width: int,
height: int,
color: str,
alpha: int = 140,
radius: int = 16,
) -> ImageClip:
# 新字幕背景仅在用户显式开启时使用:通过 RGBA 图片绘制圆角半透明底板,
# 再交给 MoviePy 作为透明 ImageClip 参与合成。这样默认路径完全不变,
# 同时可以低成本试验更柔和的字幕视觉效果。
rgb = _hex_to_rgb(color)
safe_alpha = max(0, min(255, int(alpha)))
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.rounded_rectangle(
[0, 0, max(0, width - 1), max(0, height - 1)],
radius=max(0, int(radius)),
fill=(rgb[0], rgb[1], rgb[2], safe_alpha),
)
return ImageClip(np.array(img), transparent=True)
def _get_visible_center_position(
text_clip: TextClip,
container_width: int,
container_height: int,
) -> tuple[int, int]:
"""
按文字真实可见像素把 TextClip 放到背景容器中心。
MoviePy 的 TextClip 会按字体行高和 baseline 创建透明画布。很多字体的
可见字形并不在这个画布的几何中心,直接 `with_position("center")`
会把整块透明画布居中,导致字幕看起来偏上或偏下。这里读取 TextClip
的透明 mask,只根据实际有像素的 bbox 计算偏移,让用户看到的文字
在字幕背景里视觉居中。
"""
x = int(round((container_width - text_clip.w) / 2))
y = int(round((container_height - text_clip.h) / 2))
try:
if text_clip.mask is None:
return x, y
mask_frame = text_clip.mask.get_frame(0)
ys, _ = np.where(mask_frame > 0.01)
if len(ys) == 0:
return x, y
visible_top = int(ys.min())
visible_bottom = int(ys.max())
visible_height = visible_bottom - visible_top + 1
y = int(round((container_height - visible_height) / 2 - visible_top))
except Exception as exc:
logger.debug(f"failed to center subtitle text by visible mask: {str(exc)}")
return x, y
def subtitle_colors_are_indistinguishable(params: VideoParams) -> bool:
"""判断字幕文字和背景是否同色,提醒用户可能无法看清字幕。"""
if not params.subtitle_enabled or not params.text_background_color:
return False
def normalize_color(value):
if isinstance(value, bool):
return "#000000" if value else ""
return str(value or "").strip().lower()
text_color = normalize_color(params.text_fore_color)
background_color = normalize_color(params.text_background_color)
return bool(text_color and text_color == background_color)
@lru_cache(maxsize=64)
def _subtitle_font_supports_sample(font_path: str, sample: str) -> bool:
"""检查字体是否包含样本文字需要的字形,并缓存重复检查结果。"""
try:
font = ImageFont.truetype(font_path, 30)
missing_mask = font.getmask("\U0010ffff")
missing_signature = (
missing_mask.size,
missing_mask.getbbox(),
bytes(missing_mask),
)
for char in sample:
char_mask = font.getmask(char)
char_signature = (
char_mask.size,
char_mask.getbbox(),
bytes(char_mask),
)
if char_mask.getbbox() is None or char_signature == missing_signature:
return False
return True
except Exception as e:
# 字体探测失败不应阻止用户生成;保留日志供环境兼容问题排查。
logger.warning(f"failed to inspect subtitle font glyphs: {font_path}, {e}")
return True
def subtitle_font_supports_text(font_path: str, text: str) -> bool:
"""检查字体能否绘制文本中的字母和数字,忽略空白及标点符号。"""
sample = "".join(
dict.fromkeys(
char
for char in str(text or "")
if unicodedata.category(char)[0] in {"L", "N"}
)
)[:64]
if not sample:
return True
return _subtitle_font_supports_sample(font_path, sample)
def generate_video(
video_path: str,
audio_path: str,
subtitle_path: str,
output_file: str,
params: VideoParams,
):
aspect = VideoAspect(params.video_aspect)
video_width, video_height = aspect.to_resolution()
logger.info(f"generating video: {video_width} x {video_height}")
logger.info(f" ① video: {video_path}")
logger.info(f" ② audio: {audio_path}")
logger.info(f" ③ subtitle: {subtitle_path}")
logger.info(f" ④ output: {output_file}")
# https://github.com/harry0703/MoneyPrinterTurbo/issues/217
# 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'
# write into the same directory as the output file
output_dir = os.path.dirname(output_file)
font_path = ""
if params.subtitle_enabled:
if not params.font_name:
params.font_name = "STHeitiMedium.ttc"
font_path = os.path.join(utils.font_dir(), params.font_name)
if os.name == "nt":
font_path = font_path.replace("\\", "/")
logger.info(f" ⑤ font: {font_path}")
def resolve_subtitle_background_color():
# 兼容历史参数:API 里 `text_background_color` 既可能是布尔值,
# 也可能是实际颜色字符串。统一在这里归一化,避免把 True/False
# 直接传给 TextClip 后出现不可预期的渲染结果。
if isinstance(params.text_background_color, bool):
return "#000000" if params.text_background_color else None
return params.text_background_color
def create_text_clip(subtitle_item):
params.font_size = int(params.font_size)
params.stroke_width = int(params.stroke_width)
phrase = subtitle_item[1]
max_width = video_width * 0.9
bg_color = resolve_subtitle_background_color()
rounded_bg_enabled = bool(
getattr(params, "rounded_subtitle_background", False) and bg_color
)
has_subtitle_background = bool(bg_color)
# 圆角背景按文字真实宽度生成,左右留白应更克制;旧矩形背景仍保留
# 较大的安全边距,避免历史配置中的长字幕贴边或被裁切。
padding_ratio = 0.4 if rounded_bg_enabled else 0.6
pad_x = int(params.font_size * padding_ratio) if has_subtitle_background else 0
# 字幕背景需要给文字左右留出明确内边距。先从可用宽度中扣除
# padding 再换行,避免长英文或大字号刚好撑满 90% 视频宽度后,
# 文字贴到背景框边缘,看起来像被裁切。普通矩形背景和圆角背景
# 都走这条逻辑;无背景字幕则保持原有最大宽度。
text_max_width = max(1, int(max_width) - 2 * pad_x)
wrapped_txt, txt_height = wrap_text(
phrase,
max_width=text_max_width,
font=font_path,
fontsize=params.font_size,
)
interline = int(params.font_size * 0.25)
line_count = wrapped_txt.count("\n") + 1
vertical_padding = int(params.font_size * 0.35)
text_clip_margin_y = max(
int(params.font_size * 0.3), int(params.stroke_width * 2)
)
# MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、
# 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入
# 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕
# 背景框与文字本身都能完整渲染出来。
clip_h = int(txt_height + vertical_padding + (interline * line_count))
if rounded_bg_enabled:
# 圆角背景需要贴合文字宽度,而不是沿用 90% 视频宽度。这里先用
# PIL 测量最长一行文字,再加水平内边距,避免短字幕出现过宽底板。
try:
font = ImageFont.truetype(font_path, params.font_size)
text_w = max(
int(font.getbbox(line)[2] - font.getbbox(line)[0])
for line in wrapped_txt.split("\n")
)
except Exception as exc:
logger.warning(
f"failed to measure subtitle text width, fallback to max width: {str(exc)}"
)
text_w = int(max_width)
box_w = max(1, min(int(max_width), text_w + 2 * pad_x))
radius = max(8, int(params.font_size * 0.4))
text_clip = TextClip(
text=wrapped_txt,
font=font_path,
font_size=params.font_size,
color=params.text_fore_color,
bg_color=None,
stroke_color=params.stroke_color,
stroke_width=params.stroke_width,
interline=interline,
size=(box_w, None),
text_align="center",
margin=(0, text_clip_margin_y),
)
clip_h = max(clip_h, text_clip.h)
bg_clip = _rounded_subtitle_background_clip(
width=box_w,
height=clip_h,
color=bg_color,
alpha=140,
radius=radius,
)
text_position = _get_visible_center_position(text_clip, box_w, clip_h)
_clip = CompositeVideoClip(
[bg_clip, text_clip.with_position(text_position)],
size=(box_w, clip_h),
)
elif bg_color:
size = (
int(max_width),
clip_h,
)
text_clip = TextClip(
text=wrapped_txt,
font=font_path,
font_size=params.font_size,
color=params.text_fore_color,
bg_color=None,
stroke_color=params.stroke_color,
stroke_width=params.stroke_width,
interline=interline,
size=(int(max_width), None),
text_align="center",
margin=(0, text_clip_margin_y),
)
size = (size[0], max(size[1], text_clip.h))
bg_clip = _rounded_subtitle_background_clip(
width=size[0],
height=size[1],
color=bg_color,
alpha=255,
radius=0,
)
text_position = _get_visible_center_position(text_clip, size[0], size[1])
_clip = CompositeVideoClip(
[bg_clip, text_clip.with_position(text_position)],
size=size,
)
else:
size = (
int(max_width),
clip_h,
)
_clip = TextClip(
text=wrapped_txt,
font=font_path,
font_size=params.font_size,
color=params.text_fore_color,
bg_color=None,
stroke_color=params.stroke_color,
stroke_width=params.stroke_width,
interline=interline,
size=size,
text_align="center",
)
duration = subtitle_item[0][1] - subtitle_item[0][0]
_clip = _clip.with_start(subtitle_item[0][0])
_clip = _clip.with_end(subtitle_item[0][1])
_clip = _clip.with_duration(duration)
if params.subtitle_position == "bottom":
_clip = _clip.with_position(("center", video_height * 0.95 - _clip.h))
elif params.subtitle_position == "top":
_clip = _clip.with_position(("center", video_height * 0.05))
elif params.subtitle_position == "custom":
# Ensure the subtitle is fully within the screen bounds
margin = 10 # Additional margin, in pixels
max_y = video_height - _clip.h - margin
min_y = margin
custom_y = (video_height - _clip.h) * (params.custom_position / 100)
custom_y = max(
min_y, min(custom_y, max_y)
) # Constrain the y value within the valid range
_clip = _clip.with_position(("center", custom_y))
else: # center
_clip = _clip.with_position(("center", "center"))
return _clip
video_clip = _open_video_clip_quietly(video_path)
audio_clip = AudioFileClip(audio_path).with_effects(
[afx.MultiplyVolume(params.voice_volume)]
)
def make_textclip(text):
return TextClip(
text=text,
font=font_path,
font_size=params.font_size,
)
if subtitle_path and os.path.exists(subtitle_path):
sub = SubtitlesClip(
subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip
)
text_clips = []
for item in sub.subtitles:
clip = create_text_clip(subtitle_item=item)
text_clips.append(clip)
video_clip = CompositeVideoClip([video_clip, *text_clips])
bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file)
if bgm_file:
try:
bgm_clip = AudioFileClip(bgm_file).with_effects(
[
afx.MultiplyVolume(params.bgm_volume),
afx.AudioFadeOut(3),
afx.AudioLoop(duration=video_clip.duration),
]
)
audio_clip = CompositeAudioClip([audio_clip, bgm_clip])
except Exception as e:
logger.error(f"failed to add bgm: {str(e)}")
video_clip = video_clip.with_audio(audio_clip)
# 显式沿用输入音频的采样率;如果取不到,再回退到 MoviePy 默认的 44100Hz。
# 这样可以减少不同运行环境,尤其是 Docker 环境中再次重采样带来的音质波动。
output_audio_fps = int(getattr(audio_clip, "fps", 0) or 44100)
_write_videofile_with_codec_fallback(
video_clip,
output_file=output_file,
codec=_get_configured_video_codec(),
audio_codec=audio_codec,
audio_fps=output_audio_fps,
audio_bitrate=audio_bitrate,
temp_audiofile_path=_get_temp_audio_dir(output_dir),
threads=params.n_threads or 2,
logger=None,
fps=fps,
)
video_clip.close()
del video_clip
def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
# WebUI 在某些二次生成场景下可能传入空素材列表,这里直接返回空结果,避免抛出 NoneType 异常。
if not materials:
return []
# 仅返回通过预处理校验的素材,避免低分辨率图片继续进入后续的视频合成流程。
valid_materials = []
local_videos_dir = utils.storage_dir("local_videos", create=True)
for material in materials:
if not material.url:
continue
try:
material_source_path = file_security.resolve_path_within_directory(
local_videos_dir, material.url
)
except ValueError as exc:
# local video_source 的素材路径来自 API 参数,必须限制在专用素材目录。
# 允许用户传文件名,也兼容历史返回的绝对路径,但不允许逃逸到系统
# 其他目录,避免任意文件读取或通过 MoviePy 探测本地敏感文件。
logger.warning(
f"skip unsafe local material: {material.url}, "
f"local_videos_dir: {local_videos_dir}, error: {str(exc)}"
)
continue
ext = utils.parse_extension(material_source_path)
try:
# 图片素材直接按图片方式读取,避免先走 VideoFileClip 误判后触发不稳定的回退分支。
if ext in const.FILE_TYPE_IMAGES:
clip, material_source_path = _open_image_clip_with_fallback(
material_source_path
)
else:
clip = _open_video_clip_quietly(material_source_path)
except Exception:
# 非标准扩展名或探测失败时再回退到图片模式,兼容历史上直接传本地图片路径的情况。
try:
clip, material_source_path = _open_image_clip_with_fallback(
material_source_path
)
except Exception as exc:
logger.warning(
f"skip unreadable local material: {material.url}, error: {str(exc)}"
)
continue
try:
width = clip.size[0]
height = clip.size[1]
if not is_material_resolution_acceptable(width, height):
logger.warning(
f"low resolution material: {width}x{height}, minimum "
f"{_MIN_MATERIAL_DIMENSION}x{_MIN_MATERIAL_DIMENSION} required "
f"(tolerance {_MIN_DIMENSION_TOLERANCE}px)"
)
# 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。
close_clip(clip)
continue
if ext in const.FILE_TYPE_IMAGES:
logger.info(f"processing image: {material_source_path}")
# 探测尺寸时已经打开过一次素材,这里先释放探测句柄,再重新创建用于导出的图片 clip。
close_clip(clip)
# Create an image clip and set its duration to 3 seconds
clip = (
ImageClip(material_source_path)
.with_duration(clip_duration)
.with_position("center")
)
# Apply a zoom effect using the resize method.
# A lambda function is used to make the zoom effect dynamic over time.
# The zoom effect starts from the original size and gradually scales up to 120%.
# t represents the current time, and clip.duration is the total duration of the clip (3 seconds).
# Note: 1 represents 100% size, so 1.2 represents 120% size.
zoom_clip = clip.resized(
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
)
# Optionally, create a composite video clip containing the zoomed clip.
# This is useful when you want to add other elements to the video.
final_clip = CompositeVideoClip([zoom_clip])
# Output the video to a file.
video_file = f"{material_source_path}.mp4"
final_clip.write_videofile(video_file, fps=30, logger=None)
close_clip(clip)
close_clip(final_clip)
material.url = video_file
logger.success(f"image processed: {video_file}")
else:
# 普通视频素材只需要读取尺寸做校验,校验完成后立即释放句柄即可。
close_clip(clip)
# Update url to the resolved absolute path so that downstream
# stages (combine_videos) can open the file without re-resolving.
material.url = material_source_path
except Exception:
close_clip(clip)
raise
valid_materials.append(material)
return valid_materials