Files
MoneyPrinterTurbo/app/services/video.py
2026-06-12 14:59:20 +08:00

790 lines
31 KiB
Python
Raw 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 gc
import glob
import io
import itertools
import os
import random
import shutil
import subprocess
from contextlib import redirect_stdout
from typing import List
from PIL import Image, ImageFont
from loguru import logger
from moviepy import (
AudioFileClip,
ColorClip,
CompositeAudioClip,
CompositeVideoClip,
ImageClip,
TextClip,
VideoFileClip,
afx,
)
from moviepy.video.tools.subtitles import SubtitlesClip
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):
self.file_path = file_path
self.start_time = start_time
self.end_time = end_time
self.width = width
self.height = height
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"
video_codec = "libx264"
fps = 30
_BGM_EXTENSIONS = (".mp3",)
def get_ffmpeg_binary():
# 优先复用用户在 config.toml / 环境变量里显式指定的 ffmpeg可避免
# Windows 便携包、Docker、自定义安装目录等场景下 PATH 不一致。
configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE")
if configured_ffmpeg:
return configured_ffmpeg
system_ffmpeg = shutil.which("ffmpeg")
if system_ffmpeg:
return system_ffmpeg
try:
import imageio_ffmpeg
bundled_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
if bundled_ffmpeg:
return bundled_ffmpeg
except Exception as exc:
logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}")
return "ffmpeg"
def _escape_ffmpeg_concat_path(file_path: str) -> str:
# concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。
return file_path.replace("'", "'\\''")
def concat_video_clips_with_ffmpeg(
clip_files: List[str], output_file: str, threads: int, output_dir: str
):
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:
absolute_path = os.path.abspath(clip_file)
fp.write(f"file '{_escape_ffmpeg_concat_path(absolute_path)}'\n")
command = [
get_ffmpeg_binary(),
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_list_file,
"-c:v",
video_codec,
"-threads",
str(threads or 2),
"-pix_fmt",
"yuv420p",
output_file,
]
try:
# 使用 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")
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):
print("video_path", video_path)
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")
# 兼容 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,
)
)
start_time = end_time
if video_concat_mode.value == VideoConcatMode.sequential.value:
break
# random subclipped_items order
if video_concat_mode.value == VideoConcatMode.random.value:
random.shuffle(subclipped_items)
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 > audio_duration:
break
logger.debug(
f"processing clip {i + 1}: {subclipped_item.width}x{subclipped_item.height}, current duration: {video_duration:.2f}s, remaining: {audio_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.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),
]
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"
clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec)
# 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))
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 matches or exceeds the audio duration.
if video_duration < audio_duration:
logger.warning(
f"video duration ({video_duration:.2f}s) is shorter than audio duration ({audio_duration:.2f}s), looping clips to match audio length.")
base_clips = processed_clips.copy()
for clip in itertools.cycle(base_clips):
if video_duration >= audio_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, 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
# if there is only one clip, use it directly
if len(processed_clips) == 1:
logger.info("using single clip directly")
shutil.copy(processed_clips[0].file_path, combined_video_path)
delete_files([processed_clips[0].file_path])
logger.info("video combining completed")
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,
)
# 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)
result = "\n".join(line.strip() for line in lines if line.strip()).strip()
height = len(lines) * height
return result, height
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
wrapped_txt, txt_height = wrap_text(
phrase, max_width=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)
# MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、
# 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入
# 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕
# 背景框与文字本身都能完整渲染出来。
size = (
int(max_width),
int(txt_height + vertical_padding + (interline * line_count)),
)
_clip = TextClip(
text=wrapped_txt,
font=font_path,
font_size=params.font_size,
color=params.text_fore_color,
bg_color=resolve_subtitle_background_color(),
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)
video_clip.write_videofile(
output_file,
audio_codec=audio_codec,
audio_fps=output_audio_fps,
audio_bitrate=audio_bitrate,
temp_audiofile_path=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 width < 480 or height < 480:
logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required")
# 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。
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)
except Exception:
close_clip(clip)
raise
valid_materials.append(material)
return valid_materials