初始化提交

This commit is contained in:
lvyulong
2026-06-12 14:59:20 +08:00
parent c1d00901a8
commit 00718f47f3
85 changed files with 15799 additions and 0 deletions

789
app/services/video.py Normal file
View File

@@ -0,0 +1,789 @@
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