From b9b5ecc61ccb1ba9df3a2ccaf0987fba34d4f84a Mon Sep 17 00:00:00 2001 From: vermont Date: Sat, 13 Jan 2024 13:02:43 -0500 Subject: [PATCH 01/80] Fix decorator performance. --- moviepy/decorators.py | 61 +++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/moviepy/decorators.py b/moviepy/decorators.py index 621e35853..c33edb006 100644 --- a/moviepy/decorators.py +++ b/moviepy/decorators.py @@ -78,22 +78,28 @@ def audio_video_fx(func, clip, *args, **kwargs): return func(clip, *args, **kwargs) -def preprocess_args(fun, varnames): - """Applies fun to variables in varnames before launching the function.""" +def preprocess_args(preprocess_func, varnames): + """Applies preprocess_func to variables in varnames before launching + the function. + """ - def wrapper(func, *args, **kwargs): - names = inspect.getfullargspec(func).args - new_args = [ - fun(arg) if (name in varnames) and (arg is not None) else arg - for (arg, name) in zip(args, names) - ] - new_kwargs = { - kwarg: fun(value) if kwarg in varnames else value - for (kwarg, value) in kwargs.items() - } - return func(*new_args, **new_kwargs) + def decor(func): + argnames = inspect.getfullargspec(func).args + + def wrapper(func, *args, **kwargs): + new_args = [ + preprocess_func(arg) if (name in varnames) and (arg is not None) else arg + for (arg, name) in zip(args, argnames) + ] + new_kwargs = { + kwarg: preprocess_func(value) if kwarg in varnames else value + for (kwarg, value) in kwargs.items() + } + return func(*new_args, **new_kwargs) + + return decorator.decorate(func, wrapper) - return decorator.decorator(wrapper) + return decor def convert_parameter_to_seconds(varnames): @@ -114,11 +120,12 @@ def add_mask_if_none(func, clip, *args, **kwargs): return func(clip, *args, **kwargs) -@decorator.decorator -def use_clip_fps_by_default(func, clip, *args, **kwargs): +def use_clip_fps_by_default(func): """Will use ``clip.fps`` if no ``fps=...`` is provided in **kwargs**.""" - def find_fps(fps): + argnames = inspect.getfullargspec(func).args[1:] + + def find_fps(clip, fps): if fps is not None: return fps elif getattr(clip, "fps", None): @@ -130,14 +137,16 @@ def find_fps(fps): " the clip's fps with `clip.fps=24`" % func.__name__ ) - names = inspect.getfullargspec(func).args[1:] + def wrapper(func, clip, *args, **kwargs): + new_args = [ + find_fps(clip, arg) if name == "fps" else arg + for (arg, name) in zip(args, argnames) + ] + new_kwargs = { + kwarg: find_fps(clip, kwarg) if kwarg == "fps" else value + for (kwarg, value) in kwargs.items() + } - new_args = [ - find_fps(arg) if (name == "fps") else arg for (arg, name) in zip(args, names) - ] - new_kwargs = { - kwarg: find_fps(value) if kwarg == "fps" else value - for (kwarg, value) in kwargs.items() - } + return func(clip, *new_args, **new_kwargs) - return func(clip, *new_args, **new_kwargs) + return decorator.decorate(func, wrapper) From e79854124cc70b90758a69c84b5458867b4dbef7 Mon Sep 17 00:00:00 2001 From: MofaAI Date: Sat, 24 Feb 2024 10:06:49 +0800 Subject: [PATCH 02/80] Fix GPU h264_nvenc encoding not working. --- moviepy/video/io/ffmpeg_writer.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/moviepy/video/io/ffmpeg_writer.py b/moviepy/video/io/ffmpeg_writer.py index 9319850cb..5bd05fa60 100644 --- a/moviepy/video/io/ffmpeg_writer.py +++ b/moviepy/video/io/ffmpeg_writer.py @@ -120,7 +120,14 @@ def __init__( ] if audiofile is not None: cmd.extend(["-i", audiofile, "-acodec", "copy"]) - cmd.extend(["-vcodec", codec, "-preset", preset]) + + if (codec == "h264_nvenc") : + cmd.extend(["-c:v", codec]) + else : + cmd.extend(["-vcodec", codec]) + + cmd.extend(["-preset", preset]) + if ffmpeg_params is not None: cmd.extend(ffmpeg_params) if bitrate is not None: @@ -129,8 +136,9 @@ def __init__( if threads is not None: cmd.extend(["-threads", str(threads)]) - if (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0): + if (codec == "libx264" or codec == "h264_nvenc") and (size[0] % 2 == 0) and (size[1] % 2 == 0): cmd.extend(["-pix_fmt", "yuv420p"]) + cmd.extend([filename]) popen_params = cross_platform_popen_params( From c507dad341306b21c83a1bbfd4cd44c69c1cebde Mon Sep 17 00:00:00 2001 From: vermont Date: Sat, 9 Mar 2024 14:01:18 -0500 Subject: [PATCH 03/80] Fix formatting. --- moviepy/audio/fx/all/__init__.py | 1 + moviepy/decorators.py | 7 ++++++- moviepy/tools.py | 1 + moviepy/video/fx/all/__init__.py | 1 + moviepy/video/io/ffmpeg_reader.py | 13 +++++++------ moviepy/video/tools/credits.py | 1 + moviepy/video/tools/cuts.py | 6 +++--- 7 files changed, 20 insertions(+), 10 deletions(-) diff --git a/moviepy/audio/fx/all/__init__.py b/moviepy/audio/fx/all/__init__.py index c6cadf1c6..d51e65959 100644 --- a/moviepy/audio/fx/all/__init__.py +++ b/moviepy/audio/fx/all/__init__.py @@ -4,6 +4,7 @@ Use the fx method directly from the clip instance (e.g. ``clip.audio_normalize(...)``) or import the function from moviepy.audio.fx instead. """ + import warnings from .. import * # noqa 401,F403 diff --git a/moviepy/decorators.py b/moviepy/decorators.py index c33edb006..1177f214c 100644 --- a/moviepy/decorators.py +++ b/moviepy/decorators.py @@ -1,4 +1,5 @@ """Decorators used by moviepy.""" + import inspect import os @@ -88,7 +89,11 @@ def decor(func): def wrapper(func, *args, **kwargs): new_args = [ - preprocess_func(arg) if (name in varnames) and (arg is not None) else arg + ( + preprocess_func(arg) + if (name in varnames) and (arg is not None) + else arg + ) for (arg, name) in zip(args, argnames) ] new_kwargs = { diff --git a/moviepy/tools.py b/moviepy/tools.py index 8817c2d95..2778cd2d5 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -1,4 +1,5 @@ """Misc. useful functions that can be used at many places in the program.""" + import os import subprocess as sp import warnings diff --git a/moviepy/video/fx/all/__init__.py b/moviepy/video/fx/all/__init__.py index 34b0627dd..0e4d6f2f0 100644 --- a/moviepy/video/fx/all/__init__.py +++ b/moviepy/video/fx/all/__init__.py @@ -4,6 +4,7 @@ Use the fx method directly from the clip instance (e.g. ``clip.resize(...)``) or import the function from moviepy.video.fx instead. """ + import warnings from moviepy.video.fx import * # noqa F401,F403 diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 616deff6b..700b18604 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -1,4 +1,5 @@ """Implements all the functions to read a video or a picture using ffmpeg.""" + import os import re import subprocess as sp @@ -452,12 +453,12 @@ def parse(self): # for default streams, set their numbers globally, so it's # easy to get without iterating all if self._current_stream["default"]: - self.result[ - f"default_{stream_type_lower}_input_number" - ] = input_number - self.result[ - f"default_{stream_type_lower}_stream_number" - ] = stream_number + self.result[f"default_{stream_type_lower}_input_number"] = ( + input_number + ) + self.result[f"default_{stream_type_lower}_stream_number"] = ( + stream_number + ) # exit chapter if self._current_chapter: diff --git a/moviepy/video/tools/credits.py b/moviepy/video/tools/credits.py index f5b04025d..a7bf9fb5c 100644 --- a/moviepy/video/tools/credits.py +++ b/moviepy/video/tools/credits.py @@ -1,6 +1,7 @@ """Contains different functions to make end and opening credits, even though it is difficult to fill everyone needs in this matter. """ + from moviepy.decorators import convert_path_to_string from moviepy.video.compositing.CompositeVideoClip import CompositeVideoClip from moviepy.video.fx.resize import resize diff --git a/moviepy/video/tools/cuts.py b/moviepy/video/tools/cuts.py index 40ff5fca3..bb6efdfef 100644 --- a/moviepy/video/tools/cuts.py +++ b/moviepy/video/tools/cuts.py @@ -253,7 +253,7 @@ def distance(t1, t2): matching_frames = [] # the final result. - for (t, frame) in clip.iter_frames(with_times=True, logger=logger): + for t, frame in clip.iter_frames(with_times=True, logger=logger): flat_frame = 1.0 * frame.flatten() F_norm_sq = dot_product(flat_frame, flat_frame) @@ -359,7 +359,7 @@ def select_scenes( nomatch_threshold = match_threshold dict_starts = defaultdict(lambda: []) - for (start, end, min_distance, max_distance) in self: + for start, end, min_distance, max_distance in self: dict_starts[start].append([end, min_distance, max_distance]) starts_ends = sorted(dict_starts.items(), key=lambda k: k[0]) @@ -445,7 +445,7 @@ def write_gifs(self, clip, gifs_dir, **kwargs): MoviePy - Building file foo/00000128_00000372.gif with imageio. MoviePy - Building file foo/00000140_00000360.gif with imageio. """ - for (start, end, _, _) in self: + for start, end, _, _ in self: name = "%s/%08d_%08d.gif" % (gifs_dir, 100 * start, 100 * end) clip.subclip(start, end).write_gif(name, **kwargs) From 5990dff635feeb0177eb55852d259496e5ef25a9 Mon Sep 17 00:00:00 2001 From: take0x <89313929+take0x@users.noreply.github.com> Date: Fri, 30 Aug 2024 12:26:35 +0900 Subject: [PATCH 04/80] Fix attribute check --- moviepy/audio/AudioClip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moviepy/audio/AudioClip.py b/moviepy/audio/AudioClip.py index 5811088b1..644eb50fc 100644 --- a/moviepy/audio/AudioClip.py +++ b/moviepy/audio/AudioClip.py @@ -230,7 +230,7 @@ def write_audiofile( """ if not fps: - if not self.fps: + if hasattr(self, "fps"): fps = 44100 else: fps = self.fps From 53e9bb5259c90990eaecd3eea6d291462851b2a4 Mon Sep 17 00:00:00 2001 From: Jonata Bolzan Loss Date: Sat, 31 Aug 2024 12:51:59 -0300 Subject: [PATCH 05/80] Add flac extension --- moviepy/tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/moviepy/tools.py b/moviepy/tools.py index 3dda375eb..e52a2c4f8 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -141,6 +141,7 @@ def deprecated_func(*args, **kwargs): "mp3": {"type": "audio", "codec": ["libmp3lame"]}, "wav": {"type": "audio", "codec": ["pcm_s16le", "pcm_s24le", "pcm_s32le"]}, "m4a": {"type": "audio", "codec": ["libfdk_aac"]}, + "flac":{"type": "audio", "codec": ["flac"]} } for ext in ["jpg", "jpeg", "png", "bmp", "tiff"]: From 5797c876dbb0cbcd2f40423b26a22f3055aba19f Mon Sep 17 00:00:00 2001 From: Daniel Gustaw Date: Sat, 7 Sep 2024 15:35:43 +0400 Subject: [PATCH 06/80] libx264 and prores as mov codecs --- moviepy/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index 3dda375eb..29407c9dc 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -136,7 +136,7 @@ def deprecated_func(*args, **kwargs): "ogv": {"type": "video", "codec": ["libtheora"]}, "webm": {"type": "video", "codec": ["libvpx"]}, "avi": {"type": "video"}, - "mov": {"type": "video"}, + "mov": {"type": "video", "codec": ["libx264", "prores"]}, "ogg": {"type": "audio", "codec": ["libvorbis"]}, "mp3": {"type": "audio", "codec": ["libmp3lame"]}, "wav": {"type": "audio", "codec": ["pcm_s16le", "pcm_s24le", "pcm_s32le"]}, From f1de2e6fb0df0d0d2a8a819d6c0d8a039c7cbc99 Mon Sep 17 00:00:00 2001 From: Jun He Date: Thu, 28 Nov 2024 10:40:23 -0600 Subject: [PATCH 07/80] Fix wrong line number Otherwise, final_clip.write_videofile("./result.mp4") will show up as the first line. --- docs/getting_started/moviepy_10_minutes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started/moviepy_10_minutes.rst b/docs/getting_started/moviepy_10_minutes.rst index 6f8e61ecc..b64d464fb 100644 --- a/docs/getting_started/moviepy_10_minutes.rst +++ b/docs/getting_started/moviepy_10_minutes.rst @@ -44,7 +44,7 @@ Let's start by importing the necessary modules and loading the "Big Buck Bunny" .. literalinclude:: /_static/code/getting_started/moviepy_10_minutes/trailer.py :language: python - :lines: 0-10 + :lines: 1-10 As you see, loading a video file is really easy, but MoviePy isn't limited to video. It can handle images, audio, texts, and even custom animations. From 284df38cb948ef409158f3da18e1bd2d6810990a Mon Sep 17 00:00:00 2001 From: aaa3334 Date: Mon, 2 Dec 2024 13:04:00 +1030 Subject: [PATCH 08/80] Update VideoClip.py Adding in a new optional parameter bg_radius. This parameter allows users to round the edges of the background text box so it is not sharp cornered. --- moviepy/video/VideoClip.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index fd43a81a2..dce433b4d 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1405,6 +1405,12 @@ class TextClip(ImageClip): a RGB (or RGBA if transparent = ``True``) ``tuple``, a color name, or an hexadecimal notation. + bg_radius + A paramater to round the edges of the text background. Defaults to 0 if there + is no background. It will have no effect if there is no bg_colour added. + The higher the value, the more rounded the corners will become. + + stroke_color Color of the stroke (=contour line) of the text. If ``None``, there will be no stroke. @@ -1451,6 +1457,7 @@ def __init__( margin=(None, None), color="black", bg_color=None, + bg_radius=0, stroke_color=None, stroke_width=0, method="label", @@ -1719,9 +1726,19 @@ def find_optimum_font_size( if bg_color is None and transparent: bg_color = (0, 0, 0, 0) - img = Image.new(img_mode, (img_width, img_height), color=bg_color) - pil_font = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) + if bg_radius is None: + bg_radius = 0 + + if bg_radius != 0: + + img = Image.new(img_mode, (img_width, img_height), color=(0,0,0,0)) + pil_font = ImageFont.truetype(font, font_size) + draw = ImageDraw.Draw(img) + draw.rounded_rectangle([0, 0, img_width, img_height], radius=bg_radius, fill=bg_color) + else: + img = Image.new(img_mode, (img_width, img_height), color=bg_color) + pil_font = ImageFont.truetype(font, font_size) + draw = ImageDraw.Draw(img) # Dont need allow break here, because we already breaked in caption text_width, text_height = find_text_size( From 5e05a956c26b828a8338dba2ccfc68a6f2d292eb Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 2 Dec 2024 13:46:59 +0200 Subject: [PATCH 09/80] Correct SubtitlesClip example to not pass the generator lambda as `font` --- moviepy/video/tools/subtitles.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/moviepy/video/tools/subtitles.py b/moviepy/video/tools/subtitles.py index b240f2de4..ef181ad69 100644 --- a/moviepy/video/tools/subtitles.py +++ b/moviepy/video/tools/subtitles.py @@ -46,8 +46,7 @@ class SubtitlesClip(VideoClip): from moviepy.video.io.VideoFileClip import VideoFileClip generator = lambda text: TextClip(text, font='Georgia-Regular', font_size=24, color='white') - sub = SubtitlesClip("subtitles.srt", generator) - sub = SubtitlesClip("subtitles.srt", generator, encoding='utf-8') + sub = SubtitlesClip("subtitles.srt", make_textclip=generator, encoding='utf-8') myvideo = VideoFileClip("myvideo.avi") final = CompositeVideoClip([clip, subtitles]) final.write_videofile("final.mp4", fps=myvideo.fps) From e6332dc4ff7f356562cb5d6d5553a41948e63aa3 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Thu, 5 Dec 2024 23:17:32 +0100 Subject: [PATCH 10/80] Fix issue #2160 based on PR #2163 relative to filenames starting with a dash and improved for filenames with specialchars --- media/-video-with-dash-.mp4 | Bin 0 -> 1687 bytes moviepy/audio/io/ffmpeg_audiowriter.py | 6 ++--- moviepy/audio/io/readers.py | 6 ++--- moviepy/tools.py | 13 +++++++++++ moviepy/video/io/ffmpeg_reader.py | 12 ++++++---- moviepy/video/io/ffmpeg_tools.py | 31 ++++++++++++++++--------- moviepy/video/io/ffmpeg_writer.py | 8 +++---- tests/test_issues.py | 9 +++++++ tests/test_tools.py | 17 ++++++++++++++ 9 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 media/-video-with-dash-.mp4 diff --git a/media/-video-with-dash-.mp4 b/media/-video-with-dash-.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ad89c0e66f83f75cd9d3417e3e06577a41186e9a GIT binary patch literal 1687 zcmZuyUuYaf7@xZkDMe5*g%lE)U@aDt%kCzZrqRWmp){gJMJyu1GPgT>yWIZE%;a)c z!KM<*D$n)aFLXB#AZ9e0M(Zjcaj#B}E@d5uDF6={oi#GKNAM zuh8wd0Ch)TrSS4F)Y7=K+(ZfsNGHkjwumtC_Ix^teQagdVew3rDf~DUhPDxBMyf=B z1b;JGRifwld0L2aL#U8ZBF-QIq7uXjl}M#Vt(soZ1C9gN0TzKM36->ypWmK$_qpX&H_u@vIlL~sareHOXCb=?aNI1t2m=YyHtV-s-%vvTKV3+ z(^6dduCGF#Hc?eN6(+7TQ?vLAoyE0kbAScumZRvpbEn|YMiwYxT;PiE9B_ls5-YfU zx=Xf>_j^=c+F(ce+y7nPmRDc?^o8o?h3b35bN{SAf#7Ikb0TSYzrO!!_2che?~ZKU z`sMD4Z=x%-aICTU;`0+%PHY_MQ~cV8<7Y0O{r;0XKYn(6{37BbjZOch+m9W8qk4Ei z_$t4!J@9Yb=f9rnK0csYn&%zFle7~B828!GAB;VDiB%Z=?b&@3xDU)1_m~euReY>( zq3Ue}-!bhn=g>aljvDeFv;%n_nwK~O-Z2>%joFw?G=3mTLxgnKcQ}=sX%GuUcp4l$ z`yx1t?9ChHx~V8gREf~hr@xD7JV!qT%OVRBNu0{Ncn%@JNGB~t2iqYgW5MejAZ*YcRdxh3`mXaLB_zp*N^sb{mh2y zQsrNExPO}+ZDG88?b*8<6Nm6JSlK<^y*&AKg$+k{e!D^9quP{pB_T>O%-AB~5p0+0 zO-6odqy7Wjb-39WxIO{cE*Cl_eg?7gWn|F7U;PPi42`3W?lk1{b6_&ku+W;Tfe{$r xPGva)vWfk7hx=BdD5$*mY$_f+kf*;eISG#A9}0<(D0@Y8!uov8v1+zu{SPtNh!X$+ literal 0 HcmV?d00001 diff --git a/moviepy/audio/io/ffmpeg_audiowriter.py b/moviepy/audio/io/ffmpeg_audiowriter.py index 3d7fea660..7b0123a15 100644 --- a/moviepy/audio/io/ffmpeg_audiowriter.py +++ b/moviepy/audio/io/ffmpeg_audiowriter.py @@ -6,7 +6,7 @@ from moviepy.config import FFMPEG_BINARY from moviepy.decorators import requires_duration -from moviepy.tools import cross_platform_popen_params +from moviepy.tools import cross_platform_popen_params, ffmpeg_escape_filename class FFMPEG_AudioWriter: @@ -89,7 +89,7 @@ def __init__( if input_video is None: cmd.extend(["-vn"]) else: - cmd.extend(["-i", input_video, "-vcodec", "copy"]) + cmd.extend(["-i", ffmpeg_escape_filename(input_video), "-vcodec", "copy"]) cmd.extend(["-acodec", codec] + ["-ar", "%d" % fps_input]) cmd.extend(["-strict", "-2"]) # needed to support codec 'aac' @@ -97,7 +97,7 @@ def __init__( cmd.extend(["-ab", bitrate]) if ffmpeg_params is not None: cmd.extend(ffmpeg_params) - cmd.extend([filename]) + cmd.extend([ffmpeg_escape_filename(filename)]) popen_params = cross_platform_popen_params( {"stdout": sp.DEVNULL, "stderr": logfile, "stdin": sp.PIPE} diff --git a/moviepy/audio/io/readers.py b/moviepy/audio/io/readers.py index 80f0f6dea..5d57f5488 100644 --- a/moviepy/audio/io/readers.py +++ b/moviepy/audio/io/readers.py @@ -6,7 +6,7 @@ import numpy as np from moviepy.config import FFMPEG_BINARY -from moviepy.tools import cross_platform_popen_params +from moviepy.tools import cross_platform_popen_params, ffmpeg_escape_filename from moviepy.video.io.ffmpeg_reader import ffmpeg_parse_infos @@ -80,13 +80,13 @@ def initialize(self, start_time=0): "-ss", "%.05f" % (start_time - offset), "-i", - self.filename, + ffmpeg_escape_filename(self.filename), "-vn", "-ss", "%.05f" % offset, ] else: - i_arg = ["-i", self.filename, "-vn"] + i_arg = ["-i", ffmpeg_escape_filename(self.filename), "-vn"] cmd = ( [FFMPEG_BINARY] diff --git a/moviepy/tools.py b/moviepy/tools.py index 52917b40f..fab9791f0 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -4,6 +4,7 @@ import platform import subprocess as sp import warnings +import shlex import proglog @@ -50,6 +51,18 @@ def subprocess_call(cmd, logger="bar"): del proc +def ffmpeg_escape_filename(filename): + """Escape a filename that we want to pass to the ffmpeg command line + + That will ensure the filename doesn't start with a '-' (which would raise an error) + and use `shlex.quote` to escape filenames with spaces and special chars. + """ + if filename.startswith('-') : + filename = './' + filename + + return shlex.quote(filename) + + def convert_to_seconds(time): """Will convert any time into seconds. diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index b22aa2b6d..810842ed3 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -8,7 +8,11 @@ import numpy as np from moviepy.config import FFMPEG_BINARY # ffmpeg, ffmpeg.exe, etc... -from moviepy.tools import convert_to_seconds, cross_platform_popen_params +from moviepy.tools import ( + convert_to_seconds, + cross_platform_popen_params, + ffmpeg_escape_filename +) class FFMPEG_VideoReader: @@ -91,12 +95,12 @@ def initialize(self, start_time=0): "-ss", "%.06f" % (start_time - offset), "-i", - self.filename, + ffmpeg_escape_filename(self.filename), "-ss", "%.06f" % offset, ] else: - i_arg = ["-i", self.filename] + i_arg = ["-i", ffmpeg_escape_filename(self.filename)] cmd = ( [FFMPEG_BINARY] @@ -801,7 +805,7 @@ def ffmpeg_parse_infos( https://github.com/Zulko/moviepy/pull/1222). """ # Open the file in a pipe, read output - cmd = [FFMPEG_BINARY, "-hide_banner", "-i", filename] + cmd = [FFMPEG_BINARY, "-hide_banner", "-i", ffmpeg_escape_filename(filename)] if decode_file: cmd.extend(["-f", "null", "-"]) diff --git a/moviepy/video/io/ffmpeg_tools.py b/moviepy/video/io/ffmpeg_tools.py index 610a8cc9c..926d07317 100644 --- a/moviepy/video/io/ffmpeg_tools.py +++ b/moviepy/video/io/ffmpeg_tools.py @@ -4,7 +4,7 @@ from moviepy.config import FFMPEG_BINARY from moviepy.decorators import convert_parameter_to_seconds, convert_path_to_string -from moviepy.tools import subprocess_call +from moviepy.tools import subprocess_call, ffmpeg_escape_filename @convert_path_to_string(("inputfile", "outputfile")) @@ -41,7 +41,7 @@ def ffmpeg_extract_subclip( "-ss", "%0.2f" % start_time, "-i", - inputfile, + ffmpeg_escape_filename(inputfile), "-t", "%0.2f" % (end_time - start_time), "-map", @@ -51,7 +51,7 @@ def ffmpeg_extract_subclip( "-acodec", "copy", "-copyts", - outputfile, + ffmpeg_escape_filename(outputfile), ] subprocess_call(cmd, logger=logger) @@ -89,14 +89,14 @@ def ffmpeg_merge_video_audio( FFMPEG_BINARY, "-y", "-i", - audiofile, + ffmpeg_escape_filename(audiofile), "-i", - videofile, + ffmpeg_escape_filename(videofile), "-vcodec", video_codec, "-acodec", audio_codec, - outputfile, + ffmpeg_escape_filename(outputfile), ] subprocess_call(cmd, logger=logger) @@ -125,12 +125,12 @@ def ffmpeg_extract_audio(inputfile, outputfile, bitrate=3000, fps=44100, logger= FFMPEG_BINARY, "-y", "-i", - inputfile, + ffmpeg_escape_filename(inputfile), "-ab", "%dk" % bitrate, "-ar", "%d" % fps, - outputfile, + ffmpeg_escape_filename(outputfile), ] subprocess_call(cmd, logger=logger) @@ -154,10 +154,10 @@ def ffmpeg_resize(inputfile, outputfile, size, logger="bar"): cmd = [ FFMPEG_BINARY, "-i", - inputfile, + ffmpeg_escape_filename(inputfile), "-vf", "scale=%d:%d" % (size[0], size[1]), - outputfile, + ffmpeg_escape_filename(outputfile), ] subprocess_call(cmd, logger=logger) @@ -194,7 +194,16 @@ def ffmpeg_stabilize_video( outputfile = f"{name}_stabilized{ext}" outputfile = os.path.join(output_dir, outputfile) - cmd = [FFMPEG_BINARY, "-i", inputfile, "-vf", "deshake", outputfile] + cmd = [ + FFMPEG_BINARY, + "-i", + ffmpeg_escape_filename(inputfile), + "-vf", + "deshake", + ffmpeg_escape_filename(outputfile) + ] + if overwrite_file: cmd.append("-y") + subprocess_call(cmd, logger=logger) diff --git a/moviepy/video/io/ffmpeg_writer.py b/moviepy/video/io/ffmpeg_writer.py index cbae415a0..e66cb488f 100644 --- a/moviepy/video/io/ffmpeg_writer.py +++ b/moviepy/video/io/ffmpeg_writer.py @@ -9,7 +9,7 @@ from proglog import proglog from moviepy.config import FFMPEG_BINARY -from moviepy.tools import cross_platform_popen_params +from moviepy.tools import cross_platform_popen_params, ffmpeg_escape_filename class FFMPEG_VideoWriter: @@ -119,7 +119,7 @@ def __init__( "-", ] if audiofile is not None: - cmd.extend(["-i", audiofile, "-acodec", "copy"]) + cmd.extend(["-i", ffmpeg_escape_filename(audiofile), "-acodec", "copy"]) cmd.extend(["-vcodec", codec, "-preset", preset]) if ffmpeg_params is not None: cmd.extend(ffmpeg_params) @@ -131,7 +131,7 @@ def __init__( if (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0): cmd.extend(["-pix_fmt", "yuv420p"]) - cmd.extend([filename]) + cmd.extend([ffmpeg_escape_filename(filename)]) popen_params = cross_platform_popen_params( {"stdout": sp.DEVNULL, "stderr": logfile, "stdin": sp.PIPE} @@ -306,7 +306,7 @@ def ffmpeg_write_image(filename, image, logfile=False, pixel_format=None): pixel_format, "-i", "-", - filename, + ffmpeg_escape_filename(filename), ] if logfile: diff --git a/tests/test_issues.py b/tests/test_issues.py index ca61cc1f8..4bf46bd5e 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -360,5 +360,14 @@ def test_issue_1682_2(util): clip.write_audiofile(output_audio_filepath) +def test_issue_2160(util): + filename = "media/-video-with-dash-.mp4" + clip = VideoFileClip(filename) + output_video_filepath = os.path.join( + util.TMP_DIR, "big_buck_bunny_0_30_cutout.webm" + ) + clip.write_videofile(output_video_filepath) + + if __name__ == "__main__": pytest.main() diff --git a/tests/test_tools.py b/tests/test_tools.py index 0e4e5807c..52cadf32d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -65,6 +65,23 @@ def test_subprocess_call(command): tools.subprocess_call(command, logger=None) +@pytest.mark.parametrize( + "given, expected", + [ + ("-filenamethatstartswithdash-.mp4", "./-filenamethatstartswithdash-.mp4"), + ("-path/that/starts/with/dash.mp4", "./-path/that/starts/with/dash.mp4"), + ("file-name-.mp4", "file-name-.mp4"), + ("/absolute/path/to/-file.mp4", "/absolute/path/to/-file.mp4"), + ("filename with spaces.mp4", "'filename with spaces.mp4'") + ], +) +def test_ffmpeg_escape_filename(given, expected): + """Test the ffmpeg_escape_filename function outputs correct paths as per + the docstring. + """ + assert tools.ffmpeg_escape_filename(given) == expected + + @pytest.mark.parametrize("os_name", (os.name, "nt")) def test_cross_platform_popen_params(os_name, monkeypatch): tools_module = importlib.import_module("moviepy.tools") From 061c141f70cdf23bfebd0f41aaef91bf3c8f5456 Mon Sep 17 00:00:00 2001 From: philipqueen Date: Thu, 19 Dec 2024 12:54:35 -0700 Subject: [PATCH 11/80] Update macOS version to 13 in test_suite.yml This gets the macOS testing working again. Github will no longer run jobs using the `macOS-12` version. This updates to the next lowest version, `macOS-13`. --- .github/workflows/test_suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index d72a4bbb3..2fa45be62 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -14,7 +14,7 @@ on: jobs: # Uses Python Framework build because on macOS, Matplotlib requires it macos: - runs-on: macos-12 + runs-on: macos-13 strategy: matrix: python-version: ["3.9", "3.10", "3.11"] From 51572906316e16a9aee2b68b25a6f4bbee2c0e5d Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 22 Dec 2024 16:50:06 +0100 Subject: [PATCH 12/80] Fix multiple bugs in transparency, mainly in video compositing, still need to fix reading i think --- moviepy/video/VideoClip.py | 89 ++++++++++++++++++- .../video/compositing/CompositeVideoClip.py | 10 +++ moviepy/video/io/ffmpeg_writer.py | 24 +++-- moviepy/video/tools/drawing.py | 6 +- tests/test_compositing.py | 55 ++++++++---- tests/test_issues.py | 53 +++++++++++ 6 files changed, 210 insertions(+), 27 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index fd43a81a2..a76e0daf3 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -718,6 +718,8 @@ def blit_on(self, picture, t): """Returns the result of the blit of the clip's frame at time `t` on the given `picture`, the position of the clip being given by the clip's ``pos`` attribute. Meant for compositing. + + (note: blitting is the fact of putting an image on a surface or another image) """ wf, hf = picture.size @@ -744,7 +746,6 @@ def blit_on(self, picture, t): im_mask_bg.paste(im_mask, (0, 0)) im_img, im_mask = im_img_bg, im_mask_bg - else: im_mask = None @@ -780,6 +781,92 @@ def blit_on(self, picture, t): pos = map(int, pos) return blit(im_img, picture, pos, mask=im_mask) + + def blit_mask(self, base_mask, t): + """Returns the result of the blit of the clip's mask at time `t` + on the given `base_mask`, the position of the clip being given + by the clip's ``pos`` attribute. Meant for compositing. + + (warning: only use this function to blit two masks together, never images) + + (note: blitting is the fact of putting an image on a surface or another image) + """ + ct = t - self.start # clip time + clip_mask = self.get_frame(ct).astype("float") + + # numpy shape is H*W not W*H + hbm, wbm = base_mask.shape + hcm, wcm = clip_mask.shape + + # SET POSITION + pos = self.pos(ct) + + # preprocess short writings of the position + if isinstance(pos, str): + pos = { + "center": ["center", "center"], + "left": ["left", "center"], + "right": ["right", "center"], + "top": ["center", "top"], + "bottom": ["center", "bottom"], + }[pos] + else: + pos = list(pos) + + # is the position relative (given in % of the clip's size) ? + if self.relative_pos: + for i, dim in enumerate([wbm, hbm]): + if not isinstance(pos[i], str): + pos[i] = dim * pos[i] + + if isinstance(pos[0], str): + D = {"left": 0, "center": (wbm - wcm) / 2, "right": wbm - wcm} + pos[0] = int(D[pos[0]]) + + if isinstance(pos[1], str): + D = {"top": 0, "center": (hbm - hcm) / 2, "bottom": hbm - hcm} + pos[1] = int(D[pos[1]]) + + # ALPHA COMPOSITING + # Determine the base_mask region to merge size + x_start = max(pos[0], 0) # Dont go under 0 left + x_end = min(pos[0] + wcm, wbm) # Dont go over base_mask width + y_start = max(pos[1], 0) # Dont go under 0 top + y_end = min(pos[1] + hcm, hbm) # Dont go over base_mask height + + # Determine the clip_mask region to overlapp + # Dont go under 0 for horizontal, if we have negative margin of X px start at X + # And dont go over clip width + clip_x_start = max(0, -pos[0]) + clip_x_end = clip_x_start + min((x_end - x_start), (wcm - clip_x_start)) + # same for vertical + clip_y_start = max(0, -pos[1]) + clip_y_end = clip_y_start + min((y_end - y_start), (hcm - clip_y_start)) + + # Blend the overlapping regions + # The calculus is base_opacity + clip_opacity * (1 - base_opacity) + # this ensure that masks are drawn in the right order and + # the contribution of each mask is proportional to their transparency + # + # Note : + # Thinking in transparency is hard, as we tend to think + # that 50% opaque + 40% opaque = 90% opacity, when it really its 70% + # It's a lot easier to think in terms of "passing light" + # Consider I emit 100 photons, and my first layer is 50% opaque, meaning it + # will "stop" 50% of the photons, I'll have 50 photons left + # now my second layer is blocking 40% of thoses 50 photons left + # blocking 50 * 0.4 = 20 photons, and leaving me with only 30 photons + # So, by adding two layer of 50% and 40% opacity my finaly opacity is only + # of (100-30)*100 = 70% opacity ! + base_mask[y_start:y_end, x_start:x_end] = ( + base_mask[y_start:y_end, x_start:x_end] + + clip_mask[clip_y_start:clip_y_end, clip_x_start:clip_x_end] * + (1 - base_mask[y_start:y_end, x_start:x_end]) + ) + + return base_mask + + def with_background_color(self, size=None, color=(0, 0, 0), pos=None, opacity=None): """Place the clip on a colored background. diff --git a/moviepy/video/compositing/CompositeVideoClip.py b/moviepy/video/compositing/CompositeVideoClip.py index 28b32b1f9..3a2964151 100644 --- a/moviepy/video/compositing/CompositeVideoClip.py +++ b/moviepy/video/compositing/CompositeVideoClip.py @@ -67,6 +67,8 @@ def __init__( if bg_color is None: bg_color = 0.0 if is_mask else (0, 0, 0) + print(clips) + fpss = [clip.fps for clip in clips if getattr(clip, "fps", None)] self.fps = max(fpss) if fpss else None @@ -121,6 +123,14 @@ def frame_function(self, t): frame = self.bg.get_frame(t).astype("uint8") im = Image.fromarray(frame) + # For the mask we dont blit on each other, instead we recalculate the final transparency of the masks + if self.is_mask : + mask = np.zeros((self.size[1], self.size[0]), dtype=float) + for clip in self.playing_clips(t): + mask = clip.blit_mask(mask, t) + + return mask + if self.bg.mask is not None: frame_mask = self.bg.mask.get_frame(t) im_mask = Image.fromarray(255 * frame_mask).convert("L") diff --git a/moviepy/video/io/ffmpeg_writer.py b/moviepy/video/io/ffmpeg_writer.py index cbae415a0..632e6edc4 100644 --- a/moviepy/video/io/ffmpeg_writer.py +++ b/moviepy/video/io/ffmpeg_writer.py @@ -95,8 +95,8 @@ def __init__( self.filename = filename self.codec = codec self.ext = self.filename.split(".")[-1] - if not pixel_format: # pragma: no cover - pixel_format = "rgba" if with_mask else "rgb24" + + pixel_format = "rgba" if with_mask else "rgb24" # order is important cmd = [ @@ -120,17 +120,25 @@ def __init__( ] if audiofile is not None: cmd.extend(["-i", audiofile, "-acodec", "copy"]) + cmd.extend(["-vcodec", codec, "-preset", preset]) + if ffmpeg_params is not None: cmd.extend(ffmpeg_params) + if bitrate is not None: cmd.extend(["-b", bitrate]) if threads is not None: cmd.extend(["-threads", str(threads)]) - if (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0): - cmd.extend(["-pix_fmt", "yuv420p"]) + # Disable auto alt ref for transparent webm and set pix format yo yuva420p + if codec == 'libvpx' and with_mask : + cmd.extend(["-pix_fmt", 'yuva420p']) + cmd.extend(["-auto-alt-ref", '0']) + elif (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0) : + cmd.extend(["-pix_fmt", 'yuva420p']) + cmd.extend([filename]) popen_params = cross_platform_popen_params( @@ -237,9 +245,11 @@ def ffmpeg_write_video( logfile = open(filename + ".log", "w+") else: logfile = None + logger(message="MoviePy - Writing video %s\n" % filename) - if not pixel_format: - pixel_format = "rgba" if clip.mask is not None else "rgb24" + + has_mask = clip.mask is not None + with FFMPEG_VideoWriter( filename, clip.size, @@ -247,6 +257,7 @@ def ffmpeg_write_video( codec=codec, preset=preset, bitrate=bitrate, + with_mask=has_mask, logfile=logfile, audiofile=audiofile, threads=threads, @@ -292,6 +303,7 @@ def ffmpeg_write_image(filename, image, logfile=False, pixel_format=None): """ if image.dtype != "uint8": image = image.astype("uint8") + if not pixel_format: pixel_format = "rgba" if (image.shape[2] == 4) else "rgb24" diff --git a/moviepy/video/tools/drawing.py b/moviepy/video/tools/drawing.py index 77b68b9ba..cb11b817d 100644 --- a/moviepy/video/tools/drawing.py +++ b/moviepy/video/tools/drawing.py @@ -3,10 +3,11 @@ """ import numpy as np +from PIL import Image -def blit(im1, im2, pos=None, mask=None): - """Blit an image over another. +def blit(im1: Image, im2: Image, pos=None, mask: Image = None): + """Blit an image over another using pillow. Blits ``im1`` on ``im2`` as position ``pos=(x,y)``, using the ``mask`` if provided. @@ -16,6 +17,7 @@ def blit(im1, im2, pos=None, mask=None): else: # Cast to tuple in case pos is not subscriptable. pos = tuple(pos) + im2.paste(im1, pos, mask) return im2 diff --git a/tests/test_compositing.py b/tests/test_compositing.py index 43018d747..fb42f830e 100644 --- a/tests/test_compositing.py +++ b/tests/test_compositing.py @@ -88,26 +88,45 @@ def test_concatenate_floating_point(util): concat.write_videofile(os.path.join(util.TMP_DIR, "concat.mp4"), preset="ultrafast") -# def test_blit_with_opacity(): -# # bitmap.mp4 has one second R, one second G, one second B -# clip1 = VideoFileClip("media/bitmap.mp4") -# # overlay same clip, shifted by 1 second, at half opacity -# clip2 = ( -# VideoFileClip("media/bitmap.mp4") -# .subclip(1, 2) -# .with_start(0) -# .with_end(2) -# .with_opacity(0.5) -# ) -# composite = CompositeVideoClip([clip1, clip2]) -# bt = ClipPixelTest(composite) - -# bt.expect_color_at(0.5, (0x7F, 0x7F, 0x00)) -# bt.expect_color_at(1.5, (0x00, 0x7F, 0x7F)) -# bt.expect_color_at(2.5, (0x00, 0x00, 0xFF)) +def test_blit_with_opacity(): + # has one second R, one second G, one second B + size = (2, 2) + clip1 = ( + ColorClip(size, color=(255, 0, 0), duration=1) + + ColorClip(size, color=(0, 255, 0), duration=1) + + ColorClip(size, color=(0, 0, 255), duration=1) + ) + # overlay green at half opacity during first 2 sec + clip2 = ColorClip(size, color=(0, 255, 0), duration=2).with_opacity(0.5) + composite = CompositeVideoClip([clip1, clip2]) + bt = ClipPixelTest(composite) + + # red + 50% green + bt.expect_color_at(0.5, (0x7F, 0x7F, 0x00)) + # green + 50% green + bt.expect_color_at(1.5, (0x00, 0xFF, 0x00)) + # blue is after 2s, so keep untouched + bt.expect_color_at(2.5, (0x00, 0x00, 0xFF)) + + +def test_transparent_rendering(util): + # Has one R 30%, one G 30%, one B 30% + clip1 = ColorClip((100, 100), (255, 0, 0, 76.5)).with_duration(2) + clip2 = ColorClip((50, 50), (0, 255, 0, 76.5)).with_duration(2) + clip3 = ColorClip((25, 25), (0, 0, 255, 76.5)).with_duration(2) + + compostite_clip1 = CompositeVideoClip([clip1, clip2.with_position(('center', 'center'))]) + compostite_clip2 = CompositeVideoClip([compostite_clip1, clip3.with_position(('center', 'center'))]) + + output_filepath = os.path.join(util.TMP_DIR, "opacity.webm") + compostite_clip2.write_videofile(output_filepath, fps=5) + + # Load output file and check transparency + output_file = VideoFileClip(output_filepath, has_mask = True) + + -def test_blit_with_opacity(): # has one second R, one second G, one second B size = (2, 2) clip1 = ( diff --git a/tests/test_issues.py b/tests/test_issues.py index ca61cc1f8..47c6826c5 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -360,5 +360,58 @@ def test_issue_1682_2(util): clip.write_audiofile(output_audio_filepath) +def test_issue_2269(util): + filename = "media/big_buck_bunny_0_30.webm" + clip = VideoFileClip(filename).subclipped(0, 3) + color_clip = ColorClip((500, 200), (255, 0, 0, 255)).with_duration(3) + txt_clip_with_margin = TextClip(text="Hello", font=util.FONT, font_size=72, stroke_color="white", stroke_width=10, margin=(10,5,0,0), text_align="center").with_duration(3) + + comp1 = CompositeVideoClip([color_clip, txt_clip_with_margin.with_position(("center", "center"))]) + comp2 = CompositeVideoClip([clip, comp1.with_position(("center", "center"))]) + + # If transparency work as expected, this pixel should be pure red at 2 seconds + frame = comp2.get_frame(2) + pixel = frame[334, 625] + assert pixel == [255, 0, 0] + + +def test_issue_2269_2(util): + clip1 = ColorClip((200, 200), (255, 0, 0)).with_duration(3) + clip2 = ColorClip((100, 100), (0, 255, 0, 76.5)).with_duration(3) + clip3 = ColorClip((50, 50), (0, 0, 255, 76.5)).with_duration(3) + + compostite_clip1 = CompositeVideoClip([clip1, clip2.with_position(('center', 'center'))]) + compostite_clip2 = CompositeVideoClip([compostite_clip1, clip3.with_position(('center', 'center'))]) + + # If transparency work as expected the clip should match thoses colors + frame = compostite_clip2.get_frame(2) + pixel1 = frame[100, 10] + pixel2 = frame[100, 60] + pixel3 = frame[100, 100] + assert pixel1 == [255, 0, 0] + assert pixel2 == [179, 76, 0] + assert pixel3 == [126, 53, 76] + + +def test_issue_2269_3(util): + # This time all clips have transparency + clip1 = ColorClip((200, 200), (255, 0, 0, 76.5)).with_duration(3) + clip2 = ColorClip((100, 100), (0, 255, 0, 76.5)).with_duration(3) + clip3 = ColorClip((50, 50), (0, 0, 255, 76.5)).with_duration(3) + + compostite_clip1 = CompositeVideoClip([clip1, clip2.with_position(('center', 'center'))]) + compostite_clip2 = CompositeVideoClip([compostite_clip1, clip3.with_position(('center', 'center'))]) + + # If transparency work as expected the clip transparency should be between 0.3 and 0.657 + frame = compostite_clip2.mask.get_frame(2) + pixel1 = frame[100, 10] + pixel2 = frame[100, 60] + pixel3 = frame[100, 100] + assert pixel1 == 0.3 + assert pixel2 == 0.51 + assert pixel3 == 0.657 + + + if __name__ == "__main__": pytest.main() From 5e404570ccfdf8401b3a36934dd75752d1103eb4 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 22 Dec 2024 18:15:38 +0100 Subject: [PATCH 13/80] temporary save --- moviepy/__init__.py | 6 ++++++ .../video/compositing/CompositeVideoClip.py | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/moviepy/__init__.py b/moviepy/__init__.py index 2d4d60c74..5b0fb4fe1 100644 --- a/moviepy/__init__.py +++ b/moviepy/__init__.py @@ -2,6 +2,12 @@ can be directly imported with ``from moviepy import *``. """ +import debugpy +debugpy.listen(("localhost", 5678)) # Use a unique port +print("Waiting for debugger to attach...") +debugpy.wait_for_client() +print("Debugger attached.") + from moviepy.audio import fx as afx from moviepy.audio.AudioClip import ( AudioArrayClip, diff --git a/moviepy/video/compositing/CompositeVideoClip.py b/moviepy/video/compositing/CompositeVideoClip.py index 3a2964151..174fdaf98 100644 --- a/moviepy/video/compositing/CompositeVideoClip.py +++ b/moviepy/video/compositing/CompositeVideoClip.py @@ -59,15 +59,20 @@ def __init__( if size is None: size = clips[0].size + if not use_bgclip and bg_color is None : + use_bgclip = True + if use_bgclip and (clips[0].mask is None): transparent = False - else: - transparent = bg_color is None - - if bg_color is None: - bg_color = 0.0 if is_mask else (0, 0, 0) - print(clips) + # If we must not use fist clip as background and we dont have a color + # we generate a black background if clip should not be transparent and + # a transparent background if transparent + if (not use_bgclip) and bg_color is None: + if transparent : + bg_color = 0.0 if is_mask else (0, 0, 0, 0) + else : + bg_color = 0.0 if is_mask else (0, 0, 0) fpss = [clip.fps for clip in clips if getattr(clip, "fps", None)] self.fps = max(fpss) if fpss else None @@ -79,11 +84,13 @@ def __init__( self.clips = clips self.bg_color = bg_color + # Use first clip as background if necessary, else use color + # either set by user or previously generated if use_bgclip: self.bg = clips[0] self.clips = clips[1:] self.created_bg = False - else: + else : self.clips = clips self.bg = ColorClip(size, color=self.bg_color, is_mask=is_mask) self.created_bg = True From 788f1f4f15f8d38dcab0b2cd45bcc007e2889828 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 27 Dec 2024 23:53:21 +0100 Subject: [PATCH 14/80] Fix reading of transparent webm not working with ffmpeg by default --- moviepy/video/io/ffmpeg_reader.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index b22aa2b6d..347d71d4a 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -98,6 +98,12 @@ def initialize(self, start_time=0): else: i_arg = ["-i", self.filename] + # For webm video with transparent layer, force libvpx-vp9 as ffmpeg native webm + # decoder dont decode alpha layer + # (see https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/) + if self.depth == 4 and self.filename[-5:] == '.webm' : + i_arg = ["-c:v", "libvpx-vp9"] + i_arg + cmd = ( [FFMPEG_BINARY] + i_arg @@ -117,6 +123,7 @@ def initialize(self, start_time=0): "-", ] ) + popen_params = cross_platform_popen_params( { "bufsize": self.bufsize, From e8a09cd52f547ea3b9c563bf584e8c9030fb22c5 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 28 Dec 2024 00:09:30 +0100 Subject: [PATCH 15/80] Add test for ffmpeg reader of transparent image --- tests/test_ffmpeg_reader.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_ffmpeg_reader.py b/tests/test_ffmpeg_reader.py index d619efcc4..0baa0e87f 100644 --- a/tests/test_ffmpeg_reader.py +++ b/tests/test_ffmpeg_reader.py @@ -770,5 +770,19 @@ def test_failure_to_release_file(util): print("Yes, on Windows this fails.") +def test_read_transparent_video(): + reader = FFMPEG_VideoReader("media/transparent.webm", pixel_format='rgba') + + # Get first frame + frame = reader.get_frame(1) + mask = frame[:, :, 3] + + # Check transparency on fully transparent part is 0 + assert mask[10, 10] == 0 + + # Check transparency on fully opaque part is 255 + assert mask[102, 18] == 255 + + if __name__ == "__main__": pytest.main() From 98031508ffb0f76db5fafb211b5fb01bc0de706f Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 28 Dec 2024 00:10:47 +0100 Subject: [PATCH 16/80] use more logic position --- tests/test_ffmpeg_reader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_ffmpeg_reader.py b/tests/test_ffmpeg_reader.py index 0baa0e87f..48b583c1c 100644 --- a/tests/test_ffmpeg_reader.py +++ b/tests/test_ffmpeg_reader.py @@ -774,14 +774,14 @@ def test_read_transparent_video(): reader = FFMPEG_VideoReader("media/transparent.webm", pixel_format='rgba') # Get first frame - frame = reader.get_frame(1) + frame = reader.get_frame(0) mask = frame[:, :, 3] # Check transparency on fully transparent part is 0 assert mask[10, 10] == 0 # Check transparency on fully opaque part is 255 - assert mask[102, 18] == 255 + assert mask[100, 100] == 255 if __name__ == "__main__": From ab8f02e8b2b10ce413c39c6bc6823ba3cebaeba1 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 28 Dec 2024 00:35:17 +0100 Subject: [PATCH 17/80] Use libvpx but dont force vp8 or vp9, let ffmpeg figure it out --- moviepy/video/io/ffmpeg_reader.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 347d71d4a..68ff641c1 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -102,7 +102,7 @@ def initialize(self, start_time=0): # decoder dont decode alpha layer # (see https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/) if self.depth == 4 and self.filename[-5:] == '.webm' : - i_arg = ["-c:v", "libvpx-vp9"] + i_arg + i_arg = ["-c:v", "libvpx"] + i_arg cmd = ( [FFMPEG_BINARY] @@ -124,6 +124,8 @@ def initialize(self, start_time=0): ] ) + print(' '.join(cmd)) + popen_params = cross_platform_popen_params( { "bufsize": self.bufsize, From 036231e0d2c05014e259cd1df7d37983e4a77784 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 29 Dec 2024 00:03:29 +0100 Subject: [PATCH 18/80] Make ffmpeg reader extract codec if possible, and use it to force codec on video reading with alpha channel for vp8 and vp9 --- media/transparent.webm | Bin 0 -> 31380 bytes moviepy/video/io/ffmpeg_reader.py | 35 +++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 media/transparent.webm diff --git a/media/transparent.webm b/media/transparent.webm new file mode 100644 index 0000000000000000000000000000000000000000..9d900a0cd36c23bcc55f5beee39a6c11730c3172 GIT binary patch literal 31380 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 I&G<3?4-OCmK>z>% literal 0 HcmV?d00001 diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 68ff641c1..311505700 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -98,11 +98,15 @@ def initialize(self, start_time=0): else: i_arg = ["-i", self.filename] - # For webm video with transparent layer, force libvpx-vp9 as ffmpeg native webm - # decoder dont decode alpha layer + # For webm video (vp8 and vp9) with transparent layer, force libvpx/libvpx-vp9 as ffmpeg + # native webm decoder dont decode alpha layer # (see https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/) - if self.depth == 4 and self.filename[-5:] == '.webm' : - i_arg = ["-c:v", "libvpx"] + i_arg + if self.depth == 4 : + codec_name = self.infos.get('video_codec_name') + if codec_name == 'vp9' : + i_arg = ["-c:v", "libvpx-vp9"] + i_arg + elif codec_name == 'vp8' : + i_arg = ["-c:v", "libvpx"] + i_arg cmd = ( [FFMPEG_BINARY] @@ -124,8 +128,6 @@ def initialize(self, start_time=0): ] ) - print(' '.join(cmd)) - popen_params = cross_platform_popen_params( { "bufsize": self.bufsize, @@ -699,6 +701,25 @@ def parse_video_stream_data(self, line): fps = x * coef stream_data["fps"] = fps + # Try to extract video codec and profile + main_info_match = re.search( + r"Video:\s(\w+)?\s(\([^)]+\))?", + line.lstrip(), + ) + if main_info_match is not None : + ( + codec_name, + profile + ) = main_info_match.groups() + stream_data['codec_name'] = codec_name + stream_data['profile'] = profile + + if self._current_stream["default"] or "video_codec_name" not in self.result: + global_data["video_codec_name"] = stream_data.get("codec_name", None) + + if self._current_stream["default"] or "video_profile" not in self.result: + global_data["video_profile"] = stream_data.get("profile", None) + if self._current_stream["default"] or "video_size" not in self.result: global_data["video_size"] = stream_data.get("size", None) if self._current_stream["default"] or "video_bitrate" not in self.result: @@ -784,6 +805,8 @@ def ffmpeg_parse_infos( - ``"audio_fps"`` - ``"audio_bitrate"`` - ``"audio_metadata"`` + - ``"video_codec_name"`` + - ``"video_profile"`` Note that "video_duration" is slightly smaller than "duration" to avoid fetching the incomplete frames at the end, which raises an error. From d31d350627423d1cf4b345fa9fdd05cf2d451df6 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 29 Dec 2024 00:20:04 +0100 Subject: [PATCH 19/80] fix regex for codec when no profile --- moviepy/video/io/ffmpeg_reader.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 311505700..daa2625a4 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -108,6 +108,8 @@ def initialize(self, start_time=0): elif codec_name == 'vp8' : i_arg = ["-c:v", "libvpx"] + i_arg + print(self.infos) + cmd = ( [FFMPEG_BINARY] + i_arg @@ -128,6 +130,8 @@ def initialize(self, start_time=0): ] ) + print(" ".join(cmd)) + popen_params = cross_platform_popen_params( { "bufsize": self.bufsize, @@ -703,7 +707,7 @@ def parse_video_stream_data(self, line): # Try to extract video codec and profile main_info_match = re.search( - r"Video:\s(\w+)?\s(\([^)]+\))?", + r"Video:\s(\w+)?\s?(\([^)]+\))?", line.lstrip(), ) if main_info_match is not None : From 5681a64d5d3b086b6ed094cb93f52c6de793a790 Mon Sep 17 00:00:00 2001 From: yeohongred Date: Sun, 29 Dec 2024 18:45:25 +0800 Subject: [PATCH 20/80] Change stroke_width from float 0.5 to int 1 --- moviepy/video/tools/subtitles.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/moviepy/video/tools/subtitles.py b/moviepy/video/tools/subtitles.py index b240f2de4..ada448c8f 100644 --- a/moviepy/video/tools/subtitles.py +++ b/moviepy/video/tools/subtitles.py @@ -72,6 +72,7 @@ def __init__(self, subtitles, font=None, make_textclip=None, encoding=None): if self.font is None: raise ValueError("Argument font is required if make_textclip is None.") + # Changed stroke_width from float 0.5 to int 1 def make_textclip(txt): return TextClip( font=self.font, @@ -79,7 +80,7 @@ def make_textclip(txt): font_size=24, color="#ffffff", stroke_color="#000000", - stroke_width=0.5, + stroke_width=1, ) self.make_textclip = make_textclip From 4feb4b16d629deaa81642df36b642044d4b57cd0 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Mon, 30 Dec 2024 23:10:37 +0100 Subject: [PATCH 21/80] Massive refactor and fixing of ffmpeg reader and writer for transparency support, all transparency was actually buggy, both during rendering and reading. Also complete refactor of CompositeVideoClip compositing to properly support tranparency with CompositeVideoClip including one or more CompositeVideoClip, and transparency in general who was completly buggy --- moviepy/__init__.py | 7 - moviepy/audio/io/readers.py | 1 - moviepy/tools.py | 67 ++++++ moviepy/video/VideoClip.py | 202 +++++++++--------- .../video/compositing/CompositeVideoClip.py | 73 +++++-- moviepy/video/fx/Scroll.py | 1 - moviepy/video/io/VideoFileClip.py | 1 + moviepy/video/io/ffmpeg_reader.py | 33 ++- moviepy/video/io/ffmpeg_writer.py | 20 +- moviepy/video/tools/drawing.py | 16 -- tests/test_VideoFileClip.py | 15 ++ tests/test_compositing.py | 77 ++++--- tests/test_ffmpeg_reader.py | 4 +- tests/test_ffmpeg_writer.py | 22 ++ tests/test_issues.py | 50 +++-- 15 files changed, 371 insertions(+), 218 deletions(-) diff --git a/moviepy/__init__.py b/moviepy/__init__.py index 5b0fb4fe1..2b9baa8d7 100644 --- a/moviepy/__init__.py +++ b/moviepy/__init__.py @@ -1,13 +1,6 @@ """Imports everything that you need from the MoviePy submodules so that every thing can be directly imported with ``from moviepy import *``. """ - -import debugpy -debugpy.listen(("localhost", 5678)) # Use a unique port -print("Waiting for debugger to attach...") -debugpy.wait_for_client() -print("Debugger attached.") - from moviepy.audio import fx as afx from moviepy.audio.AudioClip import ( AudioArrayClip, diff --git a/moviepy/audio/io/readers.py b/moviepy/audio/io/readers.py index 80f0f6dea..fdb99e324 100644 --- a/moviepy/audio/io/readers.py +++ b/moviepy/audio/io/readers.py @@ -181,7 +181,6 @@ def seek(self, pos): t = 1.0 * pos / self.fps self.initialize(t) elif pos > self.pos: - # print pos self.skip_chunk(pos - self.pos) # last case standing: pos = current pos self.pos = pos diff --git a/moviepy/tools.py b/moviepy/tools.py index 52917b40f..5eaeae3f3 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -233,3 +233,70 @@ def no_display_available() -> bool: return True return False + + +def compute_position( + clip1_size: tuple, clip2_size: tuple, pos: any, relative: bool = False +) -> tuple[int, int]: + """Return the position to put clip 1 on clip 2 based on both clip size + and the position of clip 1, as return by clip1.pos() method + + Parameters + ---------- + clip1_size : tuple + The width and height of clip1 (e.g., (width, height)). + clip2_size : tuple + The width and height of clip2 (e.g., (width, height)). + pos : Any + The position of clip1 as returned by the `clip1.pos()` method. + relative: bool + Is the position relative (% of clip size), default False. + + Returns + ------- + tuple[int, int] + A tuple (x, y) representing the top-left corner of clip1 relative to clip2. + + Notes + ----- + For more information on `pos`, see the documentation for `VideoClip.with_position`. + """ + if pos is None: + pos = (0, 0) + + # preprocess short writings of the position + if isinstance(pos, str): + pos = { + "center": ["center", "center"], + "left": ["left", "center"], + "right": ["right", "center"], + "top": ["center", "top"], + "bottom": ["center", "bottom"], + }[pos] + else: + pos = list(pos) + + # is the position relative (given in % of the clip's size) ? + if relative: + for i, dim in enumerate(clip2_size): + if not isinstance(pos[i], str): + pos[i] = dim * pos[i] + + if isinstance(pos[0], str): + D = { + "left": 0, + "center": (clip2_size[0] - clip1_size[0]) / 2, + "right": clip2_size[0] - clip1_size[0], + } + pos[0] = D[pos[0]] + + if isinstance(pos[1], str): + D = { + "top": 0, + "center": (clip2_size[1] - clip1_size[1]) / 2, + "bottom": clip2_size[1] - clip1_size[1], + } + pos[1] = D[pos[1]] + + # Return as int, rounding if necessary + return (int(pos[0]), int(pos[1])) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index a76e0daf3..e571602dc 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -34,13 +34,12 @@ requires_fps, use_clip_fps_by_default, ) -from moviepy.tools import extensions_dict, find_extension +from moviepy.tools import extensions_dict, find_extension, compute_position from moviepy.video.fx.Crop import Crop from moviepy.video.fx.Resize import Resize from moviepy.video.fx.Rotate import Rotate from moviepy.video.io.ffmpeg_writer import ffmpeg_write_video from moviepy.video.io.gif_writers import write_gif_with_imageio -from moviepy.video.tools.drawing import blit class VideoClip(Clip): @@ -714,134 +713,123 @@ def fill_array(self, pre_array, shape=(0, 0)): post_array = np.hstack((post_array, x_1)) return post_array - def blit_on(self, picture, t): - """Returns the result of the blit of the clip's frame at time `t` + def compose_on(self, background: Image.Image, t) -> Image.Image: + """Returns the result of the clip's frame at time `t` on top on the given `picture`, the position of the clip being given by the clip's ``pos`` attribute. Meant for compositing. - (note: blitting is the fact of putting an image on a surface or another image) - """ - wf, hf = picture.size + If the clip/backgrounds have transparency the transparency will + be accounted for. + + The return is a Pillow Image + + Parameters + ----------- + backrgound (Image) + The background image to apply current clip on top of + if the background image is transparent it must be given as a RGBA image + t + The time of clip to apply on top of clip + + Return + """ ct = t - self.start # clip time # GET IMAGE AND MASK IF ANY - img = self.get_frame(ct).astype("uint8") - im_img = Image.fromarray(img) + clip_frame = self.get_frame(ct).astype("uint8") + clip_img = Image.fromarray(clip_frame) if self.mask is not None: - mask = (self.mask.get_frame(ct) * 255).astype("uint8") - im_mask = Image.fromarray(mask).convert("L") + clip_mask = (self.mask.get_frame(ct) * 255).astype("uint8") + clip_mask_img = Image.fromarray(clip_mask).convert("L") - if im_img.size != im_mask.size: - bg_size = ( - max(im_img.size[0], im_mask.size[0]), - max(im_img.size[1], im_mask.size[1]), - ) + # Resize clip_mask_img to match clip_img, always use top left corner + if clip_mask_img.size != clip_img.size: + mask_width, mask_height = clip_mask_img.size + img_width, img_height = clip_img.size - im_img_bg = Image.new("RGB", bg_size, "black") - im_img_bg.paste(im_img, (0, 0)) - - im_mask_bg = Image.new("L", bg_size, 0) - im_mask_bg.paste(im_mask, (0, 0)) + if mask_width > img_width or mask_height > img_height: + # Crop mask if it is larger + clip_mask_img = clip_mask_img.crop((0, 0, img_width, img_height)) + else: + # Fill mask with 0 if it is smaller + new_mask = Image.new("L", (img_width, img_height), 0) + new_mask.paste(clip_mask_img, (0, 0)) + clip_mask_img = new_mask - im_img, im_mask = im_img_bg, im_mask_bg - else: - im_mask = None + clip_img = clip_img.convert("RGBA") + clip_img.putalpha(clip_mask_img) - wi, hi = im_img.size # SET POSITION pos = self.pos(ct) + pos = compute_position(clip_img.size, background.size, pos, self.relative_pos) + + # If neither background nor clip have alpha layer (check if mode end + # with A), we can juste use pillow paste + if clip_img.mode[-1] != "A" and background.mode[-1] != "A": + background.paste(clip_img, pos) + return background + + # For images with transparency we must use pillow alpha composite + # instead of a simple paste, because pillow paste dont work nicely + # with alpha compositing + if background.mode[-1] != "A": + background = background.convert("RGBA") + + if clip_img.mode[-1] != "A": + clip_img = clip_img.convert("RGBA") + + # We need both image to do the same size for alpha compositing in pillow + # so we must start by making a fully transparent canvas of background's + # size and paste our clip img into it in position pos, only then can we + # composite this canvas on top of background + canvas = Image.new("RGBA", (background.width, background.height), (0, 0, 0, 0)) + canvas.paste(clip_img, pos) + result = Image.alpha_composite(background, canvas) + return result - # preprocess short writings of the position - if isinstance(pos, str): - pos = { - "center": ["center", "center"], - "left": ["left", "center"], - "right": ["right", "center"], - "top": ["center", "top"], - "bottom": ["center", "bottom"], - }[pos] - else: - pos = list(pos) - - # is the position relative (given in % of the clip's size) ? - if self.relative_pos: - for i, dim in enumerate([wf, hf]): - if not isinstance(pos[i], str): - pos[i] = dim * pos[i] - - if isinstance(pos[0], str): - D = {"left": 0, "center": (wf - wi) / 2, "right": wf - wi} - pos[0] = D[pos[0]] - - if isinstance(pos[1], str): - D = {"top": 0, "center": (hf - hi) / 2, "bottom": hf - hi} - pos[1] = D[pos[1]] - - pos = map(int, pos) - return blit(im_img, picture, pos, mask=im_mask) - - def blit_mask(self, base_mask, t): - """Returns the result of the blit of the clip's mask at time `t` - on the given `base_mask`, the position of the clip being given + def compose_mask(self, background_mask: np.ndarray, t: float) -> np.ndarray: + """Returns the result of the clip's mask at time `t` composited + on the given `background_mask`, the position of the clip being given by the clip's ``pos`` attribute. Meant for compositing. (warning: only use this function to blit two masks together, never images) - (note: blitting is the fact of putting an image on a surface or another image) + Parameters + ---------- + background_mask: + The underlying mask onto which the clip mask will be composed. + + t: + The time position in the clip at which to extract the mask. """ ct = t - self.start # clip time clip_mask = self.get_frame(ct).astype("float") # numpy shape is H*W not W*H - hbm, wbm = base_mask.shape - hcm, wcm = clip_mask.shape + bg_h, bg_w = background_mask.shape + clip_h, clip_w = clip_mask.shape # SET POSITION pos = self.pos(ct) - - # preprocess short writings of the position - if isinstance(pos, str): - pos = { - "center": ["center", "center"], - "left": ["left", "center"], - "right": ["right", "center"], - "top": ["center", "top"], - "bottom": ["center", "bottom"], - }[pos] - else: - pos = list(pos) - - # is the position relative (given in % of the clip's size) ? - if self.relative_pos: - for i, dim in enumerate([wbm, hbm]): - if not isinstance(pos[i], str): - pos[i] = dim * pos[i] - - if isinstance(pos[0], str): - D = {"left": 0, "center": (wbm - wcm) / 2, "right": wbm - wcm} - pos[0] = int(D[pos[0]]) - - if isinstance(pos[1], str): - D = {"top": 0, "center": (hbm - hcm) / 2, "bottom": hbm - hcm} - pos[1] = int(D[pos[1]]) + pos = compute_position((clip_w, clip_h), (bg_w, bg_h), pos, self.relative_pos) # ALPHA COMPOSITING # Determine the base_mask region to merge size - x_start = max(pos[0], 0) # Dont go under 0 left - x_end = min(pos[0] + wcm, wbm) # Dont go over base_mask width - y_start = max(pos[1], 0) # Dont go under 0 top - y_end = min(pos[1] + hcm, hbm) # Dont go over base_mask height + x_start = int(max(pos[0], 0)) # Dont go under 0 left + x_end = int(min(pos[0] + clip_w, bg_w)) # Dont go over base_mask width + y_start = int(max(pos[1], 0)) # Dont go under 0 top + y_end = int(min(pos[1] + clip_h, bg_h)) # Dont go over base_mask height # Determine the clip_mask region to overlapp - # Dont go under 0 for horizontal, if we have negative margin of X px start at X + # Dont go under 0 for horizontal, if we have negative margin of X px start at X # And dont go over clip width - clip_x_start = max(0, -pos[0]) - clip_x_end = clip_x_start + min((x_end - x_start), (wcm - clip_x_start)) + clip_x_start = int(max(0, -pos[0])) + clip_x_end = int(clip_x_start + min((x_end - x_start), (clip_w - clip_x_start))) # same for vertical - clip_y_start = max(0, -pos[1]) - clip_y_end = clip_y_start + min((y_end - y_start), (hcm - clip_y_start)) + clip_y_start = int(max(0, -pos[1])) + clip_y_end = int(clip_y_start + min((y_end - y_start), (clip_h - clip_y_start))) # Blend the overlapping regions # The calculus is base_opacity + clip_opacity * (1 - base_opacity) @@ -858,15 +846,13 @@ def blit_mask(self, base_mask, t): # blocking 50 * 0.4 = 20 photons, and leaving me with only 30 photons # So, by adding two layer of 50% and 40% opacity my finaly opacity is only # of (100-30)*100 = 70% opacity ! - base_mask[y_start:y_end, x_start:x_end] = ( - base_mask[y_start:y_end, x_start:x_end] + - clip_mask[clip_y_start:clip_y_end, clip_x_start:clip_x_end] * - (1 - base_mask[y_start:y_end, x_start:x_end]) + background_mask[y_start:y_end, x_start:x_end] = background_mask[ + y_start:y_end, x_start:x_end + ] + clip_mask[clip_y_start:clip_y_end, clip_x_start:clip_x_end] * ( + 1 - background_mask[y_start:y_end, x_start:x_end] ) - return base_mask - - + return background_mask def with_background_color(self, size=None, color=(0, 0, 0), pos=None, opacity=None): """Place the clip on a colored background. @@ -944,10 +930,20 @@ def with_audio(self, audioclip): @outplace def with_mask(self, mask: Union["VideoClip", str] = "auto"): - """Set the clip's mask. + """ + Set the clip's mask. Returns a copy of the VideoClip with the mask attribute set to ``mask``, which must be a greyscale (values in 0-1) VideoClip. + + Parameters + ---------- + mask : Union["VideoClip", str], optional + The mask to apply to the clip. + If set to "auto", a default mask will be generated: + - If the clip has a constant size, a solid mask with a value of 1.0 + will be created. + - Otherwise, a dynamic solid mask will be created based on the frame size. """ if mask == "auto": if self.has_constant_size: diff --git a/moviepy/video/compositing/CompositeVideoClip.py b/moviepy/video/compositing/CompositeVideoClip.py index 174fdaf98..d38c3f6e2 100644 --- a/moviepy/video/compositing/CompositeVideoClip.py +++ b/moviepy/video/compositing/CompositeVideoClip.py @@ -59,19 +59,18 @@ def __init__( if size is None: size = clips[0].size - if not use_bgclip and bg_color is None : - use_bgclip = True - if use_bgclip and (clips[0].mask is None): transparent = False + else: + transparent = True if bg_color is None else False - # If we must not use fist clip as background and we dont have a color - # we generate a black background if clip should not be transparent and + # If we must not use first clip as background and we dont have a color + # we generate a black background if clip should not be transparent and # a transparent background if transparent if (not use_bgclip) and bg_color is None: - if transparent : + if transparent: bg_color = 0.0 if is_mask else (0, 0, 0, 0) - else : + else: bg_color = 0.0 if is_mask else (0, 0, 0) fpss = [clip.fps for clip in clips if getattr(clip, "fps", None)] @@ -90,7 +89,7 @@ def __init__( self.bg = clips[0] self.clips = clips[1:] self.created_bg = False - else : + else: self.clips = clips self.bg = ColorClip(size, color=self.bg_color, is_mask=is_mask) self.created_bg = True @@ -121,32 +120,64 @@ def __init__( for clip in self.clips ] + if use_bgclip and self.bg.mask: + maskclips = [self.bg.mask] + maskclips + self.mask = CompositeVideoClip( maskclips, self.size, is_mask=True, bg_color=0.0 ) def frame_function(self, t): """The clips playing at time `t` are blitted over one another.""" - frame = self.bg.get_frame(t).astype("uint8") - im = Image.fromarray(frame) - # For the mask we dont blit on each other, instead we recalculate the final transparency of the masks - if self.is_mask : + # For the mask we recalculate the final transparency will need + # to apply on the result image + if self.is_mask: mask = np.zeros((self.size[1], self.size[0]), dtype=float) for clip in self.playing_clips(t): - mask = clip.blit_mask(mask, t) - - return mask + mask = clip.compose_mask(mask, t) - if self.bg.mask is not None: - frame_mask = self.bg.mask.get_frame(t) - im_mask = Image.fromarray(255 * frame_mask).convert("L") - im.putalpha(im_mask) + return mask + # Try doing clip merging with pillow + bg_t = t - self.bg.start + bg_frame = self.bg.get_frame(bg_t).astype("uint8") + bg_img = Image.fromarray(bg_frame) + + if self.bg.mask: + bgm_t = t - self.bg.mask.start + bg_mask = (self.bg.mask.get_frame(bgm_t) * 255).astype("uint8") + bg_mask_img = Image.fromarray(bg_mask).convert("L") + + # Resize bg_mask_img to match bg_img, always use top left corner + if bg_mask_img.size != bg_img.size: + mask_width, mask_height = bg_mask_img.size + img_width, img_height = bg_img.size + + if mask_width > img_width or mask_height > img_height: + bg_mask_img = bg_mask_img.crop((0, 0, img_width, img_height)) + else: + new_mask = Image.new("L", (img_width, img_height), 0) + new_mask.paste(bg_mask_img, (0, 0)) + bg_mask_img = new_mask + + bg_img = bg_img.convert("RGBA") + bg_img.putalpha(bg_mask_img) + + # For each clip apply on top of current img + current_img = bg_img for clip in self.playing_clips(t): - im = clip.blit_on(im, t) + current_img = clip.compose_on(current_img, t) + + # Turn Pillow image into a numpy array + frame = np.array(current_img) + + # If frame have transparency, remove it + # our mask will take care of it during rendering + if frame.shape[2] == 4: + return frame[:, :, :3] - return np.array(im) + return frame def playing_clips(self, t=0): """Returns a list of the clips in the composite clips that are diff --git a/moviepy/video/fx/Scroll.py b/moviepy/video/fx/Scroll.py index 1d469d112..d5c9d60a8 100644 --- a/moviepy/video/fx/Scroll.py +++ b/moviepy/video/fx/Scroll.py @@ -30,7 +30,6 @@ def __init__( y_start=0, apply_to="mask", ): - self.w = w self.h = h self.x_speed = x_speed diff --git a/moviepy/video/io/VideoFileClip.py b/moviepy/video/io/VideoFileClip.py index 67ec8cf5a..0eccdb583 100644 --- a/moviepy/video/io/VideoFileClip.py +++ b/moviepy/video/io/VideoFileClip.py @@ -105,6 +105,7 @@ def __init__( # Make a reader if not pixel_format: pixel_format = "rgba" if has_mask else "rgb24" + self.reader = FFMPEG_VideoReader( filename, decode_file=decode_file, diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index daa2625a4..946fd54db 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -99,13 +99,13 @@ def initialize(self, start_time=0): i_arg = ["-i", self.filename] # For webm video (vp8 and vp9) with transparent layer, force libvpx/libvpx-vp9 as ffmpeg - # native webm decoder dont decode alpha layer + # native webm decoder dont decode alpha layer # (see https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/) - if self.depth == 4 : - codec_name = self.infos.get('video_codec_name') - if codec_name == 'vp9' : + if self.depth == 4: + codec_name = self.infos.get("video_codec_name") + if codec_name == "vp9": i_arg = ["-c:v", "libvpx-vp9"] + i_arg - elif codec_name == 'vp8' : + elif codec_name == "vp8": i_arg = ["-c:v", "libvpx"] + i_arg print(self.infos) @@ -474,12 +474,12 @@ def parse(self): # for default streams, set their numbers globally, so it's # easy to get without iterating all if self._current_stream["default"]: - self.result[f"default_{stream_type_lower}_input_number"] = ( - input_number - ) - self.result[f"default_{stream_type_lower}_stream_number"] = ( - stream_number - ) + self.result[ + f"default_{stream_type_lower}_input_number" + ] = input_number + self.result[ + f"default_{stream_type_lower}_stream_number" + ] = stream_number # exit chapter if self._current_chapter: @@ -710,13 +710,10 @@ def parse_video_stream_data(self, line): r"Video:\s(\w+)?\s?(\([^)]+\))?", line.lstrip(), ) - if main_info_match is not None : - ( - codec_name, - profile - ) = main_info_match.groups() - stream_data['codec_name'] = codec_name - stream_data['profile'] = profile + if main_info_match is not None: + (codec_name, profile) = main_info_match.groups() + stream_data["codec_name"] = codec_name + stream_data["profile"] = profile if self._current_stream["default"] or "video_codec_name" not in self.result: global_data["video_codec_name"] = stream_data.get("codec_name", None) diff --git a/moviepy/video/io/ffmpeg_writer.py b/moviepy/video/io/ffmpeg_writer.py index 632e6edc4..8d8f22f42 100644 --- a/moviepy/video/io/ffmpeg_writer.py +++ b/moviepy/video/io/ffmpeg_writer.py @@ -120,9 +120,9 @@ def __init__( ] if audiofile is not None: cmd.extend(["-i", audiofile, "-acodec", "copy"]) - + cmd.extend(["-vcodec", codec, "-preset", preset]) - + if ffmpeg_params is not None: cmd.extend(ffmpeg_params) @@ -133,11 +133,11 @@ def __init__( cmd.extend(["-threads", str(threads)]) # Disable auto alt ref for transparent webm and set pix format yo yuva420p - if codec == 'libvpx' and with_mask : - cmd.extend(["-pix_fmt", 'yuva420p']) - cmd.extend(["-auto-alt-ref", '0']) - elif (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0) : - cmd.extend(["-pix_fmt", 'yuva420p']) + if codec == "libvpx" and with_mask: + cmd.extend(["-pix_fmt", "yuva420p"]) + cmd.extend(["-auto-alt-ref", "0"]) + elif (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0): + cmd.extend(["-pix_fmt", "yuva420p"]) cmd.extend([filename]) @@ -245,11 +245,11 @@ def ffmpeg_write_video( logfile = open(filename + ".log", "w+") else: logfile = None - + logger(message="MoviePy - Writing video %s\n" % filename) - + has_mask = clip.mask is not None - + with FFMPEG_VideoWriter( filename, clip.size, diff --git a/moviepy/video/tools/drawing.py b/moviepy/video/tools/drawing.py index cb11b817d..cfbd19651 100644 --- a/moviepy/video/tools/drawing.py +++ b/moviepy/video/tools/drawing.py @@ -6,22 +6,6 @@ from PIL import Image -def blit(im1: Image, im2: Image, pos=None, mask: Image = None): - """Blit an image over another using pillow. - - Blits ``im1`` on ``im2`` as position ``pos=(x,y)``, using the - ``mask`` if provided. - """ - if pos is None: - pos = (0, 0) # pragma: no cover - else: - # Cast to tuple in case pos is not subscriptable. - pos = tuple(pos) - - im2.paste(im1, pos, mask) - return im2 - - def color_gradient( size, p1, diff --git a/tests/test_VideoFileClip.py b/tests/test_VideoFileClip.py index db06d460b..4ee33cffa 100644 --- a/tests/test_VideoFileClip.py +++ b/tests/test_VideoFileClip.py @@ -83,5 +83,20 @@ def fake__copy__(): assert copy.deepcopy(clip) == "foo" +def test_ffmpeg_transparency_mask(util): + """Test VideoFileClip and FFMPEG reading of video with transparency.""" + video_file = "media/transparent.webm" + + video = VideoFileClip(video_file, has_mask=True) + + assert video.mask is not None + + mask_frame = video.mask.get_frame(0) + assert mask_frame[100, 100] == 1.0 + assert mask_frame[10, 10] == 0 + + video.close() + + if __name__ == "__main__": pytest.main() diff --git a/tests/test_compositing.py b/tests/test_compositing.py index fb42f830e..b4749c597 100644 --- a/tests/test_compositing.py +++ b/tests/test_compositing.py @@ -110,42 +110,69 @@ def test_blit_with_opacity(): bt.expect_color_at(2.5, (0x00, 0x00, 0xFF)) -def test_transparent_rendering(util): +def test_compositing_masks(util): # Has one R 30%, one G 30%, one B 30% clip1 = ColorClip((100, 100), (255, 0, 0, 76.5)).with_duration(2) clip2 = ColorClip((50, 50), (0, 255, 0, 76.5)).with_duration(2) clip3 = ColorClip((25, 25), (0, 0, 255, 76.5)).with_duration(2) - compostite_clip1 = CompositeVideoClip([clip1, clip2.with_position(('center', 'center'))]) - compostite_clip2 = CompositeVideoClip([compostite_clip1, clip3.with_position(('center', 'center'))]) - - output_filepath = os.path.join(util.TMP_DIR, "opacity.webm") - compostite_clip2.write_videofile(output_filepath, fps=5) + compostite_clip1 = CompositeVideoClip( + [clip1, clip2.with_position(("center", "center"))] + ) + compostite_clip2 = CompositeVideoClip( + [compostite_clip1, clip3.with_position(("center", "center"))] + ) # Load output file and check transparency - output_file = VideoFileClip(output_filepath, has_mask = True) + frame = compostite_clip2.mask.get_frame(1) - + # We check opacity with one, two and three layers + # Allow for a bit of tolerance (about 1%) to account + # for rounding errors + opacity1 = frame[50, 10] + opacity2 = frame[50, 30] + opacity3 = frame[50, 50] + assert abs(opacity1 - 0.3) < 0.01 + assert abs(opacity2 - 0.51) < 0.01 + assert abs(opacity3 - 0.657) < 0.01 - # has one second R, one second G, one second B - size = (2, 2) - clip1 = ( - ColorClip(size, color=(255, 0, 0), duration=1) - + ColorClip(size, color=(0, 255, 0), duration=1) - + ColorClip(size, color=(0, 0, 255), duration=1) - ) - # overlay green at half opacity during first 2 sec - clip2 = ColorClip(size, color=(0, 255, 0), duration=2).with_opacity(0.5) - composite = CompositeVideoClip([clip1, clip2]) - bt = ClipPixelTest(composite) +def test_compositing_with_transparency_colors(util): + # Has one R 30%, one G 30%, one B 30% + clip1 = ColorClip((100, 100), (255, 0, 0, 76.5)).with_duration(2) + clip2 = ColorClip((50, 50), (0, 255, 0, 76.5)).with_duration(2) + clip3 = ColorClip((25, 25), (0, 0, 255, 76.5)).with_duration(2) - # red + 50% green - bt.expect_color_at(0.5, (0x7F, 0x7F, 0x00)) - # green + 50% green - bt.expect_color_at(1.5, (0x00, 0xFF, 0x00)) - # blue is after 2s, so keep untouched - bt.expect_color_at(2.5, (0x00, 0x00, 0xFF)) + compostite_clip1 = CompositeVideoClip( + [clip1, clip2.with_position(("center", "center"))] + ) + compostite_clip2 = CompositeVideoClip( + [compostite_clip1, clip3.with_position(("center", "center"))] + ) + + # Load output file and check transparency + frame = compostite_clip2.get_frame(1) + mask = compostite_clip2.mask.get_frame(1) + + # We check color with 1 layer + # We add a bit of tolerance (about 1%) to account + # For possible rounding errors + color1 = frame[50, 10] + opacity1 = mask[50, 10] + assert np.allclose(color1, [255, 0, 0], rtol=0.01) + assert abs(opacity1 - 0.3) < 0.01 + + # With 2 layers + color2 = frame[50, 30] + opacity2 = mask[50, 30] + assert np.allclose(color2, [105, 150, 0], rtol=0.01) + assert abs(opacity2 - 0.51) < 0.01 + + # With 3 layers + color3 = frame[50, 50] + opacity3 = mask[50, 50] + assert np.allclose(color3, [57, 82, 116], rtol=0.01) + assert abs(opacity3 - 0.657) < 0.01 def test_slide_in(): diff --git a/tests/test_ffmpeg_reader.py b/tests/test_ffmpeg_reader.py index 48b583c1c..40a520c12 100644 --- a/tests/test_ffmpeg_reader.py +++ b/tests/test_ffmpeg_reader.py @@ -771,12 +771,12 @@ def test_failure_to_release_file(util): def test_read_transparent_video(): - reader = FFMPEG_VideoReader("media/transparent.webm", pixel_format='rgba') + reader = FFMPEG_VideoReader("media/transparent.webm", pixel_format="rgba") # Get first frame frame = reader.get_frame(0) mask = frame[:, :, 3] - + # Check transparency on fully transparent part is 0 assert mask[10, 10] == 0 diff --git a/tests/test_ffmpeg_writer.py b/tests/test_ffmpeg_writer.py index 0985b61d0..433cc7952 100644 --- a/tests/test_ffmpeg_writer.py +++ b/tests/test_ffmpeg_writer.py @@ -231,3 +231,25 @@ def test_write_gif(util, clip_class, loop, with_mask): assert r == 0 assert g == 0 assert b == 255 + + +def test_transparent_video(util): + # Has one R 30% + clip = ColorClip((100, 100), (255, 0, 0, 76.5)).with_duration(2) + filename = os.path.join("/home/ajani/Téléchargements", "opacity.webm") + + ffmpeg_write_video(clip, filename, codec="libvpx", fps=5) + + # Load output file and check transparency + result = VideoFileClip(filename, has_mask=True) + + # Check for mask + assert result.mask is not None + + # Check correct opacity, allow for some tolerance (about 1%) + # to consider rounding and compressing error + frame = result.mask.get_frame(1) + opacity = frame[50, 50] + assert abs(opacity - 0.3) < 0.01 + + result.close() diff --git a/tests/test_issues.py b/tests/test_issues.py index 47c6826c5..a1bca32f4 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -1,6 +1,7 @@ """Issue tests meant to be run with pytest.""" import os +import numpy as np import pytest @@ -201,7 +202,6 @@ def posi(t): now = t - last_move[0] w = (lis[0][1] - last_move[1]) * (now / dura) h = (lis[0][2] - last_move[2]) * (now / dura) - # print t, last_move[1] + w, last_move[2] + h return (last_move[1] + w, last_move[2] + h) return (last_move[1], last_move[2]) @@ -219,7 +219,6 @@ def size(t): s = (lis1[0][3] - last_move1[3]) * (now / dura) nsw = last_move1[3] + s nsh = nsw * 1.33 - # print t, nsw, nsh return (nsw, nsh) return (last_move1[3], last_move1[3] * 1.33) @@ -364,15 +363,28 @@ def test_issue_2269(util): filename = "media/big_buck_bunny_0_30.webm" clip = VideoFileClip(filename).subclipped(0, 3) color_clip = ColorClip((500, 200), (255, 0, 0, 255)).with_duration(3) - txt_clip_with_margin = TextClip(text="Hello", font=util.FONT, font_size=72, stroke_color="white", stroke_width=10, margin=(10,5,0,0), text_align="center").with_duration(3) - - comp1 = CompositeVideoClip([color_clip, txt_clip_with_margin.with_position(("center", "center"))]) + txt_clip_with_margin = TextClip( + text="Hello", + font=util.FONT, + font_size=72, + stroke_color="white", + stroke_width=10, + margin=(10, 5, 0, 0), + text_align="center", + ).with_duration(3) + + comp1 = CompositeVideoClip( + [color_clip, txt_clip_with_margin.with_position(("center", "center"))] + ) comp2 = CompositeVideoClip([clip, comp1.with_position(("center", "center"))]) # If transparency work as expected, this pixel should be pure red at 2 seconds frame = comp2.get_frame(2) pixel = frame[334, 625] - assert pixel == [255, 0, 0] + + # We add a bit of tolerance (about 1%) to account + # For possible rounding errors + assert np.allclose(pixel, [255, 0, 0], rtol=0.01) def test_issue_2269_2(util): @@ -380,17 +392,24 @@ def test_issue_2269_2(util): clip2 = ColorClip((100, 100), (0, 255, 0, 76.5)).with_duration(3) clip3 = ColorClip((50, 50), (0, 0, 255, 76.5)).with_duration(3) - compostite_clip1 = CompositeVideoClip([clip1, clip2.with_position(('center', 'center'))]) - compostite_clip2 = CompositeVideoClip([compostite_clip1, clip3.with_position(('center', 'center'))]) + compostite_clip1 = CompositeVideoClip( + [clip1, clip2.with_position(("center", "center"))] + ) + compostite_clip2 = CompositeVideoClip( + [compostite_clip1, clip3.with_position(("center", "center"))] + ) # If transparency work as expected the clip should match thoses colors frame = compostite_clip2.get_frame(2) pixel1 = frame[100, 10] pixel2 = frame[100, 60] pixel3 = frame[100, 100] - assert pixel1 == [255, 0, 0] - assert pixel2 == [179, 76, 0] - assert pixel3 == [126, 53, 76] + + # We add a bit of tolerance (about 1%) to account + # For possible rounding errors + assert np.allclose(pixel1, [255, 0, 0], rtol=0.01) + assert np.allclose(pixel2, [179, 76, 0], rtol=0.01) + assert np.allclose(pixel3, [126, 53, 76], rtol=0.01) def test_issue_2269_3(util): @@ -399,8 +418,12 @@ def test_issue_2269_3(util): clip2 = ColorClip((100, 100), (0, 255, 0, 76.5)).with_duration(3) clip3 = ColorClip((50, 50), (0, 0, 255, 76.5)).with_duration(3) - compostite_clip1 = CompositeVideoClip([clip1, clip2.with_position(('center', 'center'))]) - compostite_clip2 = CompositeVideoClip([compostite_clip1, clip3.with_position(('center', 'center'))]) + compostite_clip1 = CompositeVideoClip( + [clip1, clip2.with_position(("center", "center"))] + ) + compostite_clip2 = CompositeVideoClip( + [compostite_clip1, clip3.with_position(("center", "center"))] + ) # If transparency work as expected the clip transparency should be between 0.3 and 0.657 frame = compostite_clip2.mask.get_frame(2) @@ -412,6 +435,5 @@ def test_issue_2269_3(util): assert pixel3 == 0.657 - if __name__ == "__main__": pytest.main() From 0a4036201a995a907e79bb26d441af94c0da334d Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 09:25:32 +0100 Subject: [PATCH 22/80] Fix path --- tests/test_ffmpeg_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ffmpeg_writer.py b/tests/test_ffmpeg_writer.py index 433cc7952..db015f67a 100644 --- a/tests/test_ffmpeg_writer.py +++ b/tests/test_ffmpeg_writer.py @@ -236,7 +236,7 @@ def test_write_gif(util, clip_class, loop, with_mask): def test_transparent_video(util): # Has one R 30% clip = ColorClip((100, 100), (255, 0, 0, 76.5)).with_duration(2) - filename = os.path.join("/home/ajani/Téléchargements", "opacity.webm") + filename = os.path.join(util.TMP_DIR, "opacity.webm") ffmpeg_write_video(clip, filename, codec="libvpx", fps=5) From a195334b61dc3c798c83b6e0963e9f20a0c46b7d Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 09:57:10 +0100 Subject: [PATCH 23/80] fix flake8 linting --- moviepy/video/VideoClip.py | 2 +- moviepy/video/compositing/CompositeVideoClip.py | 3 +-- moviepy/video/io/ffmpeg_reader.py | 8 +++++--- moviepy/video/tools/drawing.py | 1 - 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index e571602dc..4e1c7ce13 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -724,7 +724,7 @@ def compose_on(self, background: Image.Image, t) -> Image.Image: The return is a Pillow Image Parameters - ----------- + ---------- backrgound (Image) The background image to apply current clip on top of if the background image is transparent it must be given as a RGBA image diff --git a/moviepy/video/compositing/CompositeVideoClip.py b/moviepy/video/compositing/CompositeVideoClip.py index d38c3f6e2..7639bb2d5 100644 --- a/moviepy/video/compositing/CompositeVideoClip.py +++ b/moviepy/video/compositing/CompositeVideoClip.py @@ -129,8 +129,7 @@ def __init__( def frame_function(self, t): """The clips playing at time `t` are blitted over one another.""" - - # For the mask we recalculate the final transparency will need + # For the mask we recalculate the final transparency we'll need # to apply on the result image if self.is_mask: mask = np.zeros((self.size[1], self.size[0]), dtype=float) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 946fd54db..f5c601517 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -98,9 +98,11 @@ def initialize(self, start_time=0): else: i_arg = ["-i", self.filename] - # For webm video (vp8 and vp9) with transparent layer, force libvpx/libvpx-vp9 as ffmpeg - # native webm decoder dont decode alpha layer - # (see https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/) + # For webm video (vp8 and vp9) with transparent layer, force libvpx/libvpx-vp9 + # as ffmpeg native webm decoder dont decode alpha layer + # (see + # https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/ + # ) if self.depth == 4: codec_name = self.infos.get("video_codec_name") if codec_name == "vp9": diff --git a/moviepy/video/tools/drawing.py b/moviepy/video/tools/drawing.py index cfbd19651..253de9b75 100644 --- a/moviepy/video/tools/drawing.py +++ b/moviepy/video/tools/drawing.py @@ -3,7 +3,6 @@ """ import numpy as np -from PIL import Image def color_gradient( From 6528bcff687d4f4da0523ae926a48b4e2defcdbd Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 10:03:44 +0100 Subject: [PATCH 24/80] Fix isort --- .github/workflows/formatting_linting.yml | 2 +- moviepy/video/VideoClip.py | 2 +- tests/test_issues.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/formatting_linting.yml b/.github/workflows/formatting_linting.yml index a8bd0c146..eacf31d97 100644 --- a/.github/workflows/formatting_linting.yml +++ b/.github/workflows/formatting_linting.yml @@ -66,4 +66,4 @@ jobs: python -m pip install --upgrade wheel pip pip install .[lint] - name: Run isort - run: isort --check-only --diff moviepy setup.py scripts docs/conf.py examples tests + run: isort --check-only --diff moviepy scripts docs/conf.py examples tests diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 4e1c7ce13..2c94425e1 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -34,7 +34,7 @@ requires_fps, use_clip_fps_by_default, ) -from moviepy.tools import extensions_dict, find_extension, compute_position +from moviepy.tools import compute_position, extensions_dict, find_extension from moviepy.video.fx.Crop import Crop from moviepy.video.fx.Resize import Resize from moviepy.video.fx.Rotate import Rotate diff --git a/tests/test_issues.py b/tests/test_issues.py index a1bca32f4..8983ccfc5 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -1,6 +1,7 @@ """Issue tests meant to be run with pytest.""" import os + import numpy as np import pytest From 442db0f8c6b7a7bcb5a8c522ef8265dd380512a9 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 10:36:27 +0100 Subject: [PATCH 25/80] Fix dir seperator for windows --- moviepy/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index fab9791f0..6b7081af4 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -58,7 +58,7 @@ def ffmpeg_escape_filename(filename): and use `shlex.quote` to escape filenames with spaces and special chars. """ if filename.startswith('-') : - filename = './' + filename + filename = '.' + os.sep + filename return shlex.quote(filename) From 4428d2b8bc9f11c0227117941a00de27e59f1536 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 10:39:42 +0100 Subject: [PATCH 26/80] fix import order for isort --- moviepy/tools.py | 2 +- moviepy/video/io/ffmpeg_reader.py | 2 +- moviepy/video/io/ffmpeg_tools.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index 6b7081af4..148f8aa9a 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -2,9 +2,9 @@ import os import platform +import shlex import subprocess as sp import warnings -import shlex import proglog diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 810842ed3..55eb1762e 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -11,7 +11,7 @@ from moviepy.tools import ( convert_to_seconds, cross_platform_popen_params, - ffmpeg_escape_filename + ffmpeg_escape_filename, ) diff --git a/moviepy/video/io/ffmpeg_tools.py b/moviepy/video/io/ffmpeg_tools.py index 926d07317..00dfafb24 100644 --- a/moviepy/video/io/ffmpeg_tools.py +++ b/moviepy/video/io/ffmpeg_tools.py @@ -4,7 +4,7 @@ from moviepy.config import FFMPEG_BINARY from moviepy.decorators import convert_parameter_to_seconds, convert_path_to_string -from moviepy.tools import subprocess_call, ffmpeg_escape_filename +from moviepy.tools import ffmpeg_escape_filename, subprocess_call @convert_path_to_string(("inputfile", "outputfile")) From 40e7417aecb8b7842dfa376d719fa95b81bf7206 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 10:48:15 +0100 Subject: [PATCH 27/80] fix tests too --- tests/test_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_tools.py b/tests/test_tools.py index 52cadf32d..cabf950c3 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -79,6 +79,7 @@ def test_ffmpeg_escape_filename(given, expected): """Test the ffmpeg_escape_filename function outputs correct paths as per the docstring. """ + given = os.path.normpath(given) assert tools.ffmpeg_escape_filename(given) == expected From effcc1a2788f5a94ad87154511d0a31675b8924d Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 31 Dec 2024 11:13:34 +0100 Subject: [PATCH 28/80] fix tests too --- tests/test_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index cabf950c3..e15ebe5ab 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -79,7 +79,7 @@ def test_ffmpeg_escape_filename(given, expected): """Test the ffmpeg_escape_filename function outputs correct paths as per the docstring. """ - given = os.path.normpath(given) + expected = expected.replace('/', os.sep) assert tools.ffmpeg_escape_filename(given) == expected From 8f8d38bd54b63dd4a1757743be24de60887496bb Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 3 Jan 2025 19:34:02 +0100 Subject: [PATCH 29/80] Fix flake8 --- moviepy/tools.py | 4 +--- tests/test_ffmpeg_writer.py | 6 ++++++ tests/test_tools.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index 148f8aa9a..69cffccc8 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -2,7 +2,6 @@ import os import platform -import shlex import subprocess as sp import warnings @@ -55,12 +54,11 @@ def ffmpeg_escape_filename(filename): """Escape a filename that we want to pass to the ffmpeg command line That will ensure the filename doesn't start with a '-' (which would raise an error) - and use `shlex.quote` to escape filenames with spaces and special chars. """ if filename.startswith('-') : filename = '.' + os.sep + filename - return shlex.quote(filename) + return filename def convert_to_seconds(time): diff --git a/tests/test_ffmpeg_writer.py b/tests/test_ffmpeg_writer.py index 0985b61d0..418d37184 100644 --- a/tests/test_ffmpeg_writer.py +++ b/tests/test_ffmpeg_writer.py @@ -231,3 +231,9 @@ def test_write_gif(util, clip_class, loop, with_mask): assert r == 0 assert g == 0 assert b == 255 + + +def test_write_file_with_spaces(util): + filename = os.path.join(util.TMP_DIR, "name with spaces.mp4") + clip = ColorClip((1, 1), color=1, is_mask=True).with_fps(1).with_duration(0.3) + ffmpeg_write_video(clip, filename, fps=1) diff --git a/tests/test_tools.py b/tests/test_tools.py index e15ebe5ab..97984d3a7 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -72,7 +72,7 @@ def test_subprocess_call(command): ("-path/that/starts/with/dash.mp4", "./-path/that/starts/with/dash.mp4"), ("file-name-.mp4", "file-name-.mp4"), ("/absolute/path/to/-file.mp4", "/absolute/path/to/-file.mp4"), - ("filename with spaces.mp4", "'filename with spaces.mp4'") + ("filename with spaces.mp4", "filename with spaces.mp4") ], ) def test_ffmpeg_escape_filename(given, expected): From 4054309d78516d0c2d3459eb420d7134e65b75ae Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 3 Jan 2025 19:52:00 +0100 Subject: [PATCH 30/80] Dont adapt separator based on os that was not the orignal problem as I was thinking --- moviepy/tools.py | 2 +- tests/test_tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index 69cffccc8..72e9f3330 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -56,7 +56,7 @@ def ffmpeg_escape_filename(filename): That will ensure the filename doesn't start with a '-' (which would raise an error) """ if filename.startswith('-') : - filename = '.' + os.sep + filename + filename = './' + filename return filename diff --git a/tests/test_tools.py b/tests/test_tools.py index 97984d3a7..0bbbe34cf 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -65,6 +65,7 @@ def test_subprocess_call(command): tools.subprocess_call(command, logger=None) + @pytest.mark.parametrize( "given, expected", [ @@ -79,7 +80,6 @@ def test_ffmpeg_escape_filename(given, expected): """Test the ffmpeg_escape_filename function outputs correct paths as per the docstring. """ - expected = expected.replace('/', os.sep) assert tools.ffmpeg_escape_filename(given) == expected From d5a76da9ec824002e0abaf59586ac80e9c972ea1 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 3 Jan 2025 22:29:24 +0100 Subject: [PATCH 31/80] . --- tests/test_tools.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index 0bbbe34cf..49357c2f0 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -65,7 +65,6 @@ def test_subprocess_call(command): tools.subprocess_call(command, logger=None) - @pytest.mark.parametrize( "given, expected", [ From 532c251a5f778fcddabaac22e64acde55c5667f5 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 3 Jan 2025 22:54:33 +0100 Subject: [PATCH 32/80] Update subtitles.py Remove unnecessary comment --- moviepy/video/tools/subtitles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/moviepy/video/tools/subtitles.py b/moviepy/video/tools/subtitles.py index ada448c8f..9ad575f2b 100644 --- a/moviepy/video/tools/subtitles.py +++ b/moviepy/video/tools/subtitles.py @@ -72,7 +72,6 @@ def __init__(self, subtitles, font=None, make_textclip=None, encoding=None): if self.font is None: raise ValueError("Argument font is required if make_textclip is None.") - # Changed stroke_width from float 0.5 to int 1 def make_textclip(txt): return TextClip( font=self.font, From 5f887671cb3e3447f8de0f3a1af7ea9d5b4327b5 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 3 Jan 2025 22:56:51 +0100 Subject: [PATCH 33/80] Update VideoClip.py Fix stroke width description --- moviepy/video/VideoClip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index fd43a81a2..e74db4d36 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1410,7 +1410,7 @@ class TextClip(ImageClip): there will be no stroke. stroke_width - Width of the stroke, in pixels. Can be a float, like 1.5. + Width of the stroke, in pixels. Must be an int. method Either 'label' (default, the picture will be autosized so as to fit From 4d469c4efe6b7dfd53bbe529ebf8594ec3f3286b Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 3 Jan 2025 23:08:59 +0100 Subject: [PATCH 34/80] Try fixing conda for macos --- .github/workflows/test_suite.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 2fa45be62..296cb5dbd 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -15,14 +15,21 @@ jobs: # Uses Python Framework build because on macOS, Matplotlib requires it macos: runs-on: macos-13 + # Do not ignore bash profile files. From: + # https://github.com/marketplace/actions/setup-miniconda + defaults: + run: + shell: bash -l {0} strategy: matrix: python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v4.1.6 - - uses: s-weigand/setup-conda@v1.2.2 + - uses: conda-incubator/setup-miniconda@v3 with: - activate-conda: true + auto-update-conda: true + python-version: ${{ matrix.python-version }} + auto-activate-base: true - name: Install pythonw run: conda install python.app From d7d0301ec838e6a6f796c691b121c39af3b0f68a Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 5 Jan 2025 23:06:16 +0100 Subject: [PATCH 35/80] Fix issue #2269 with PR #2262, thanks to @Implosiv3 --- moviepy/video/VideoClip.py | 2 +- moviepy/video/compositing/CompositeVideoClip.py | 2 +- tests/test_compositing.py | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 2c94425e1..e803e4da6 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1404,7 +1404,7 @@ class ColorClip(ImageClip): ---------- size - Size (width, height) in pixels of the clip. + Size tuple (width, height) in pixels of the clip. color If argument ``is_mask`` is False, ``color`` indicates diff --git a/moviepy/video/compositing/CompositeVideoClip.py b/moviepy/video/compositing/CompositeVideoClip.py index 7639bb2d5..cd71b6fe6 100644 --- a/moviepy/video/compositing/CompositeVideoClip.py +++ b/moviepy/video/compositing/CompositeVideoClip.py @@ -336,7 +336,7 @@ def frame_function(t): return clips[i].get_frame(t - timings[i]) def get_mask(clip): - mask = clip.mask or ColorClip([1, 1], color=1, is_mask=True) + mask = clip.mask or ColorClip(clip.size, color=1, is_mask=True) if mask.duration is None: mask.duration = clip.duration return mask diff --git a/tests/test_compositing.py b/tests/test_compositing.py index b4749c597..23d4ec114 100644 --- a/tests/test_compositing.py +++ b/tests/test_compositing.py @@ -293,5 +293,13 @@ def test_slide_out(): assert n_reds == n_reds_expected +def test_concatenate_with_masks(util): + video_without_mask = ColorClip(size=(10, 10), color=(255, 0, 0)).with_duration(1).with_fps(1) + video_with_mask = ColorClip(size=(5, 5), color=(0, 255, 0)).with_duration(1).with_fps(1).with_mask() + + output = os.path.join(util.TMP_DIR, "test_concatenate_with_masks.mp4") + concatenate_videoclips([video_without_mask, video_with_mask]).write_videofile(output) + + if __name__ == "__main__": pytest.main() From 96253ce116d827f9fee0c49aa3f464527cd033d6 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 5 Jan 2025 23:22:56 +0100 Subject: [PATCH 36/80] formatting --- moviepy/tools.py | 4 ++-- moviepy/video/io/ffmpeg_tools.py | 2 +- tests/test_compositing.py | 17 +++++++++++++---- tests/test_ffmpeg_writer.py | 2 +- tests/test_issues.py | 2 +- tests/test_tools.py | 2 +- 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index 95166b13f..174f6c41d 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -55,8 +55,8 @@ def ffmpeg_escape_filename(filename): That will ensure the filename doesn't start with a '-' (which would raise an error) """ - if filename.startswith('-') : - filename = './' + filename + if filename.startswith("-"): + filename = "./" + filename return filename diff --git a/moviepy/video/io/ffmpeg_tools.py b/moviepy/video/io/ffmpeg_tools.py index 00dfafb24..e9716ffbb 100644 --- a/moviepy/video/io/ffmpeg_tools.py +++ b/moviepy/video/io/ffmpeg_tools.py @@ -200,7 +200,7 @@ def ffmpeg_stabilize_video( ffmpeg_escape_filename(inputfile), "-vf", "deshake", - ffmpeg_escape_filename(outputfile) + ffmpeg_escape_filename(outputfile), ] if overwrite_file: diff --git a/tests/test_compositing.py b/tests/test_compositing.py index 23d4ec114..6937007b1 100644 --- a/tests/test_compositing.py +++ b/tests/test_compositing.py @@ -294,11 +294,20 @@ def test_slide_out(): def test_concatenate_with_masks(util): - video_without_mask = ColorClip(size=(10, 10), color=(255, 0, 0)).with_duration(1).with_fps(1) - video_with_mask = ColorClip(size=(5, 5), color=(0, 255, 0)).with_duration(1).with_fps(1).with_mask() - + video_without_mask = ( + ColorClip(size=(10, 10), color=(255, 0, 0)).with_duration(1).with_fps(1) + ) + video_with_mask = ( + ColorClip(size=(5, 5), color=(0, 255, 0)) + .with_duration(1) + .with_fps(1) + .with_mask() + ) + output = os.path.join(util.TMP_DIR, "test_concatenate_with_masks.mp4") - concatenate_videoclips([video_without_mask, video_with_mask]).write_videofile(output) + concatenate_videoclips([video_without_mask, video_with_mask]).write_videofile( + output + ) if __name__ == "__main__": diff --git a/tests/test_ffmpeg_writer.py b/tests/test_ffmpeg_writer.py index a56d5096a..f450c459c 100644 --- a/tests/test_ffmpeg_writer.py +++ b/tests/test_ffmpeg_writer.py @@ -254,7 +254,7 @@ def test_transparent_video(util): result.close() - + def test_write_file_with_spaces(util): filename = os.path.join(util.TMP_DIR, "name with spaces.mp4") clip = ColorClip((1, 1), color=1, is_mask=True).with_fps(1).with_duration(0.3) diff --git a/tests/test_issues.py b/tests/test_issues.py index ed3957076..ae2d6acda 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -435,7 +435,7 @@ def test_issue_2269_3(util): assert pixel2 == 0.51 assert pixel3 == 0.657 - + def test_issue_2160(util): filename = "media/-video-with-dash-.mp4" clip = VideoFileClip(filename) diff --git a/tests/test_tools.py b/tests/test_tools.py index 49357c2f0..448a5e331 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -72,7 +72,7 @@ def test_subprocess_call(command): ("-path/that/starts/with/dash.mp4", "./-path/that/starts/with/dash.mp4"), ("file-name-.mp4", "file-name-.mp4"), ("/absolute/path/to/-file.mp4", "/absolute/path/to/-file.mp4"), - ("filename with spaces.mp4", "filename with spaces.mp4") + ("filename with spaces.mp4", "filename with spaces.mp4"), ], ) def test_ffmpeg_escape_filename(given, expected): From ae799eac6ba2a479185214828dc86ccffbe42e40 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Mon, 6 Jan 2025 00:02:38 +0100 Subject: [PATCH 37/80] Update subtitles.py Fix path to font too --- moviepy/video/tools/subtitles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moviepy/video/tools/subtitles.py b/moviepy/video/tools/subtitles.py index ef181ad69..7a1d4d1c1 100644 --- a/moviepy/video/tools/subtitles.py +++ b/moviepy/video/tools/subtitles.py @@ -44,7 +44,7 @@ class SubtitlesClip(VideoClip): from moviepy.video.tools.subtitles import SubtitlesClip from moviepy.video.io.VideoFileClip import VideoFileClip - generator = lambda text: TextClip(text, font='Georgia-Regular', + generator = lambda text: TextClip(text, font='./path/to/font.ttf', font_size=24, color='white') sub = SubtitlesClip("subtitles.srt", make_textclip=generator, encoding='utf-8') myvideo = VideoFileClip("myvideo.avi") From 660c0d07a8627de4dd59009cc0bc3a6edde8ff2b Mon Sep 17 00:00:00 2001 From: wcw <344078971@qq.com> Date: Tue, 7 Jan 2025 15:31:40 +0800 Subject: [PATCH 38/80] chore: skip metadata which not satisfied with "key:value" --- moviepy/video/io/ffmpeg_reader.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index f96f584d0..b8b350ad4 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -778,8 +778,13 @@ def parse_metadata_field_value( """Returns a tuple with a metadata field-value pair given a ffmpeg `-i` command output line. """ - raw_field, raw_value = line.split(":", 1) - return (raw_field.strip(" "), raw_value.strip(" ")) + info = line.split(":", 1) + + if len(info) == 2: + raw_field, raw_value = info + return (raw_field.strip(" "), raw_value.strip(" ")) + else: + return ("", "") def video_metadata_type_casting(self, field, value): """Cast needed video metadata fields to other types than the default str.""" From 5b50693aa90870f32229bce612895988730bcbc8 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Thu, 9 Jan 2025 20:19:18 +0100 Subject: [PATCH 39/80] Changelog --- CHANGELOG.md | 72 +++++++++++++++++++--------------------------------- 1 file changed, 26 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5caac733b..955ca2a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,60 +12,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Important Announcements ### Added -- Support for `copy.copy(clip)` and `copy.deepcopy(clip)` with same behaviour as `clip.copy()` [\#1442](https://github.com/Zulko/moviepy/pull/1442) -- `audio.fx.multiply_stereo_volume` to control volume by audio channels [\#1424](https://github.com/Zulko/moviepy/pull/1424) -- Support for retrieve clip frames number using `clip.n_frames` [\#1471](https://github.com/Zulko/moviepy/pull/1471) -- `AudioClip.max_volume(stereo=True)` now can return more than 2 channels [\#1464](https://github.com/Zulko/moviepy/pull/1464) -- `video.io.ffmpeg_reader.ffmpeg_parse_infos` returns data from all streams by FFmpeg inputs in attribute `inputs` [\#1466](https://github.com/Zulko/moviepy/pull/1466) -- `video.io.ffmpeg_reader.ffmpeg_parse_infos` returns metadata of the container in attribute `metadata` [\#1466](https://github.com/Zulko/moviepy/pull/1466) -- `center`, `translate` and `bg_color` arguments to `video.fx.rotate` [\#1474](https://github.com/Zulko/moviepy/pull/1474) -- `audio.fx.audio_delay` FX [\#1481](https://github.com/Zulko/moviepy/pull/1481) -- `start_time` and `end_time` optional arguments to `multiply_volume` FX which allow to specify a range applying the transformation [\#1572](https://github.com/Zulko/moviepy/pull/1572) -- `loop` argument support writing GIFs with ffmpeg for `write_gif` and `write_gif_with_tempfiles` [\#1605](https://github.com/Zulko/moviepy/pull/1605) ### Changed -- Lots of method and parameter names have been changed. This will be explained better in the documentation soon. See https://github.com/Zulko/moviepy/pull/1170 for more information. [\#1170](https://github.com/Zulko/moviepy/pull/1170) -- Changed recommended import from `import moviepy.editor` to `import moviepy`. This change is fully backwards compatible [\#1340](https://github.com/Zulko/moviepy/pull/1340) -- Renamed `audio.fx.volumex` to `audio.fx.multiply_volume` [\#1424](https://github.com/Zulko/moviepy/pull/1424) -- Renamed `cols_widths` argument of `clips_array` function by `cols_heights` [\#1465](https://github.com/Zulko/moviepy/pull/1465) -- `video_nframes` attribute of dictionary returned from `ffmpeg_parse_infos` renamed to `video_n_frames` [\#1471](https://github.com/Zulko/moviepy/pull/1471) -- Renamed `colorx` FX by `multiply_color` [\#1475](https://github.com/Zulko/moviepy/pull/1475) -- Renamed `speedx` FX by `multiply_speed` [\#1478](https://github.com/Zulko/moviepy/pull/1478) -- `make_loopable` transition must be used as FX [\#1477](https://github.com/Zulko/moviepy/pull/1477) -- `requests` package is no longer a dependency [\#1566](https://github.com/Zulko/moviepy/pull/1566) -- `accel_decel` FX raises `ValueError` if `sooness` parameter value is lower than zero [\#1546](https://github.com/Zulko/moviepy/pull/1546) -- `Clip.subclip` raise `ValueError` if `start_time >= clip.duration` (previously printing a message to stdout only if `start_time > clip.duration`) [\#1589](https://github.com/Zulko/moviepy/pull/1589) -- Allow to pass times in `HH:MM:SS` format to `t` argument of `clip.show` method [\#1594](https://github.com/Zulko/moviepy/pull/1594) -- `TextClip` now raises `ValueError` if none of the `text` or `filename` arguments are specified [\#1842](https://github.com/Zulko/moviepy/pull/1842) ### Deprecated -- `moviepy.video.fx.all` and `moviepy.audio.fx.all`. Use the fx method directly from the clip instance or import the fx function from `moviepy.video.fx` and `moviepy.audio.fx`. [\#1105](https://github.com/Zulko/moviepy/pull/1105) ### Removed -- `VideoFileClip.coreader` and `AudioFileClip.coreader` methods removed. Use `VideoFileClip.copy` and `AudioFileClip.copy` instead [\#1442](https://github.com/Zulko/moviepy/pull/1442) -- `audio.fx.audio_loop` removed. Use `video.fx.loop` instead for all types of clip [\#1451](https://github.com/Zulko/moviepy/pull/1451) -- `video.compositing.on_color` removed. Use `VideoClip.on_color` instead [\#1456](https://github.com/Zulko/moviepy/pull/1456) ### Fixed -- Fixed BitmapClip with fps != 1 not returning the correct frames or crashing [\#1333](https://github.com/Zulko/moviepy/pull/1333) -- Fixed `rotate` sometimes failing with `ValueError: axes don't match array` [\#1335](https://github.com/Zulko/moviepy/pull/1335) -- Fixed positioning error generating frames in `CompositeVideoClip` [\#1420](https://github.com/Zulko/moviepy/pull/1420) -- Changed deprecated `tostring` method by `tobytes` in `video.io.gif_writers::write_gif` [\#1429](https://github.com/Zulko/moviepy/pull/1429) -- Fixed calling `audio_normalize` on a clip with no sound causing `ZeroDivisionError` [\#1401](https://github.com/Zulko/moviepy/pull/1401) -- Fixed `freeze` FX was freezing at time minus 1 second as the end [\#1461](https://github.com/Zulko/moviepy/pull/1461) -- Fixed `Clip.cutout` transformation not being applied to audio [\#1468](https://github.com/Zulko/moviepy/pull/1468) -- Fixed arguments inconsistency in `video.tools.drawing.color_gradient` [\#1467](https://github.com/Zulko/moviepy/pull/1467) -- Fixed `fps` not defined in `CompositeAudioClip` at initialization [\#1462](https://github.com/Zulko/moviepy/pull/1462) -- Fixed `clip.preview()` crashing at exit when running inside Jupyter Notebook in Windows [\#1537](https://github.com/Zulko/moviepy/pull/1537) -- Fixed rotate FX not being applied to mask images [\#1399](https://github.com/Zulko/moviepy/pull/1399) -- Fixed opacity error blitting VideoClips [\#1552](https://github.com/Zulko/moviepy/pull/1552) -- Fixed rotation metadata of input not being taken into account rendering VideoClips [\#577](https://github.com/Zulko/moviepy/pull/577) -- Fixed mono clips crashing when `audio_fadein` FX applied [\#1574](https://github.com/Zulko/moviepy/pull/1574) -- Fixed mono clips crashing when `audio_fadeout` FX applied [\#1578](https://github.com/Zulko/moviepy/pull/1578) -- Fixed scroll FX not being scrolling [\#1591](https://github.com/Zulko/moviepy/pull/1591) -- Fixed parsing FFMPEG streams with square brackets [\#1781](https://github.com/Zulko/moviepy/pull/1781) -- Fixed audio processing for streams with missing `audio_bitrate` [\#1783](https://github.com/Zulko/moviepy/pull/1783) -- Fixed parsing language from stream output with square brackets [\#1837](https://github.com/Zulko/moviepy/pull/1837) + +## [v2.1.2](https://github.com/zulko/moviepy/tree/master) + +[Full Changelog](https://github.com/zulko/moviepy/compare/v2.1.2...HEAD) + +### Important Announcements + +Compositing and rendering of video with transparency was bugged for a long long time (probably since start of the project), if you had encountered any issue with transparency in the past we strongly suggest you to try this new release. + +### Added +- Add codec extraction during ffmpeg meta parsing if available + +### Changed + +### Deprecated + +### Removed + +### Fixed +- Massive refactor and fixing of ffmpeg reader and writer for transparency support, all transparency was actually buggy, both during rendering and reading. +- Complete refactor of CompositeVideoClip compositing to properly support tranparency with CompositeVideoClip including one or more CompositeVideoClip, and transparency in general who was completly buggy (just so many issue related, for more info take a look at pr #2307) +- Fix issue #2305: Change stroke_width from float 0.5 to int 1 +- Fix issue #2160 where filenames starting with `-` crashed file saving +- Fix issue #2247 with default mask erronous size of 1 by 1 + + ## [v2.0.0.dev2](https://github.com/zulko/moviepy/tree/v2.0.0.dev2) (2020-10-05) From 49fdce1d13f07f3a9019f71bdc58b1f5b26518be Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 12:49:35 +0100 Subject: [PATCH 40/80] fix version number --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eb8447856..a553168a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "moviepy" -version = "2.1.1" +version = "2.1.2" description = "Video editing with Python" readme = "README.md" license = { text = "MIT License" } From d2d3cc4e124c3a223a0a62ffc551573bc6b72a63 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 13:06:06 +0100 Subject: [PATCH 41/80] Add to doc release process for maintainers --- docs/developer_guide/developers_install.rst | 6 ++ docs/developer_guide/index.rst | 1 + docs/developer_guide/maintainers_publish.rst | 59 ++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 docs/developer_guide/maintainers_publish.rst diff --git a/docs/developer_guide/developers_install.rst b/docs/developer_guide/developers_install.rst index 0b2520104..da513a47a 100644 --- a/docs/developer_guide/developers_install.rst +++ b/docs/developer_guide/developers_install.rst @@ -46,6 +46,12 @@ And you can lint with: $ python -m black . +and + +.. code:: bash + + $ python3 -m flake8 -v --show-source --max-line-length=92 moviepy docs/conf.py examples tests + diff --git a/docs/developer_guide/index.rst b/docs/developer_guide/index.rst index 774c2e448..284a90d31 100644 --- a/docs/developer_guide/index.rst +++ b/docs/developer_guide/index.rst @@ -11,3 +11,4 @@ The Developers Guide covers most of the things people wanting to participate to developers_install contribution_guidelines + maintainers_publish diff --git a/docs/developer_guide/maintainers_publish.rst b/docs/developer_guide/maintainers_publish.rst new file mode 100644 index 000000000..ce548e3ea --- /dev/null +++ b/docs/developer_guide/maintainers_publish.rst @@ -0,0 +1,59 @@ +.. _maintainers_publish: + +Publishing a New Version of MoviePy +=================================== + +This section is for maintainers responsible for publishing new versions of MoviePy. Follow these steps to ensure the process is smooth and consistent: + +**Pre-requisites** +------------------ +- Ensure you have proper permissions to push changes and create releases in the MoviePy repository. + +Steps to Publish a New Version +------------------------------ + +1. **Update the `CHANGELOG.md`** + + - Add a new section for the upcoming version, respecting the format used in previous entries. + - Summarize all changes, fixes, and new features. + +2. **Update the version in `pyproject.toml`** + + - Open the `pyproject.toml` file. + - Update the `version` field to the new version, following `Semantic Versioning `_. + +3. **Commit and Push** + + - Stage your changes:: + + git add CHANGELOG.md pyproject.toml + + - Commit your changes:: + + git commit -m "Release vX.Y.Z" + + - Push your changes:: + + git push + +4. **Create a New Tag** + + - Create a tag for the new version (replace ``vX.Y.Z`` with the actual version number):: + + git tag -a vX.Y.Z -m "Release vX.Y.Z" + + - Push the tag to the remote repository:: + + git push origin vX.Y.Z + +5. **Create a New Release** + + - Go to the repository's page on GitHub (or the relevant hosting platform). + - Navigate to the "Releases" section and create a new release. + - Use the new tag (``vX.Y.Z``) and provide a description for the release. + - Copy the changelog for this version into the release description. + - Publish the release. + +GitHub actions will automatically build and publish the new release on PyPi. + +By following these steps, you ensure that each MoviePy release is well-documented, correctly versioned, and accessible to users. From f38749ee52356f6f7458262d468ac5f4eca0a7c1 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 13:10:21 +0100 Subject: [PATCH 42/80] Update ffmpeg_reader.py Fix flake8 error --- moviepy/video/io/ffmpeg_reader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index b8b350ad4..ea552f200 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -779,7 +779,6 @@ def parse_metadata_field_value( command output line. """ info = line.split(":", 1) - if len(info) == 2: raw_field, raw_value = info return (raw_field.strip(" "), raw_value.strip(" ")) From b2c0cc5e5f43173f30514a590ba6571a267ede4b Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 16:10:51 +0100 Subject: [PATCH 43/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 955ca2a1b..50b4e51f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed ### Fixed +- Fix ffmpeg reading crash when invalid metadata (see pr #2311) ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) From 25f88be056d2afc79c3fcc59207f6aee3c5aff85 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 16:18:55 +0100 Subject: [PATCH 44/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b4e51f6..065de9467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix ffmpeg reading crash when invalid metadata (see pr #2311) +- Add support for flac codec ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) From 6fe3f942744ad892f075c3a8f366040303702a27 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 16:22:44 +0100 Subject: [PATCH 45/80] Move new parameter bg_radius at the end to avoid breaking API --- moviepy/video/VideoClip.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index dce433b4d..89e563ccb 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1405,12 +1405,6 @@ class TextClip(ImageClip): a RGB (or RGBA if transparent = ``True``) ``tuple``, a color name, or an hexadecimal notation. - bg_radius - A paramater to round the edges of the text background. Defaults to 0 if there - is no background. It will have no effect if there is no bg_colour added. - The higher the value, the more rounded the corners will become. - - stroke_color Color of the stroke (=contour line) of the text. If ``None``, there will be no stroke. @@ -1444,6 +1438,11 @@ class TextClip(ImageClip): duration Duration of the clip + + bg_radius + A paramater to round the edges of the text background. Defaults to 0 if there + is no background. It will have no effect if there is no bg_colour added. + The higher the value, the more rounded the corners will become. """ @convert_path_to_string("filename") @@ -1457,7 +1456,6 @@ def __init__( margin=(None, None), color="black", bg_color=None, - bg_radius=0, stroke_color=None, stroke_width=0, method="label", @@ -1467,6 +1465,7 @@ def __init__( interline=4, transparent=True, duration=None, + bg_radius=0, # TODO : Move this with other bg_param on next breaking release ): def break_text( width, text, font, font_size, stroke_width, align, spacing From 343e286c7d8367d2ba9fb45b6f6aa713782a84cb Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 16:26:11 +0100 Subject: [PATCH 46/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 065de9467..4f2bc9f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix ffmpeg reading crash when invalid metadata (see pr #2311) - Add support for flac codec +- Add codecs to .mov files ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) From aaf1f033f1b3f00c396cdb591ac12ef67a551955 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 16:34:59 +0100 Subject: [PATCH 47/80] Update VideoClip.py --- moviepy/video/VideoClip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 89e563ccb..3ff428917 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1465,7 +1465,7 @@ def __init__( interline=4, transparent=True, duration=None, - bg_radius=0, # TODO : Move this with other bg_param on next breaking release + bg_radius=0, # TODO : Move this with other bg_param on next breaking release ): def break_text( width, text, font, font_size, stroke_width, align, spacing From 99f8f3cd49a604bcee9fccb6e4c7ed61091f0772 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 16:52:05 +0100 Subject: [PATCH 48/80] fix linting --- moviepy/video/VideoClip.py | 9 +++++---- moviepy/video/fx/Scroll.py | 1 - moviepy/video/io/ffmpeg_reader.py | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 3ff428917..d8a3a96f5 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1727,13 +1727,14 @@ def find_optimum_font_size( if bg_radius is None: bg_radius = 0 - + if bg_radius != 0: - - img = Image.new(img_mode, (img_width, img_height), color=(0,0,0,0)) + img = Image.new(img_mode, (img_width, img_height), color=(0, 0, 0, 0)) pil_font = ImageFont.truetype(font, font_size) draw = ImageDraw.Draw(img) - draw.rounded_rectangle([0, 0, img_width, img_height], radius=bg_radius, fill=bg_color) + draw.rounded_rectangle( + [0, 0, img_width, img_height], radius=bg_radius, fill=bg_color + ) else: img = Image.new(img_mode, (img_width, img_height), color=bg_color) pil_font = ImageFont.truetype(font, font_size) diff --git a/moviepy/video/fx/Scroll.py b/moviepy/video/fx/Scroll.py index 1d469d112..d5c9d60a8 100644 --- a/moviepy/video/fx/Scroll.py +++ b/moviepy/video/fx/Scroll.py @@ -30,7 +30,6 @@ def __init__( y_start=0, apply_to="mask", ): - self.w = w self.h = h self.x_speed = x_speed diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index b22aa2b6d..16700ee15 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -459,12 +459,12 @@ def parse(self): # for default streams, set their numbers globally, so it's # easy to get without iterating all if self._current_stream["default"]: - self.result[f"default_{stream_type_lower}_input_number"] = ( - input_number - ) - self.result[f"default_{stream_type_lower}_stream_number"] = ( - stream_number - ) + self.result[ + f"default_{stream_type_lower}_input_number" + ] = input_number + self.result[ + f"default_{stream_type_lower}_stream_number" + ] = stream_number # exit chapter if self._current_chapter: From 35d2ceb2e974ab124f23644225222c466d53d7da Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 17:00:13 +0100 Subject: [PATCH 49/80] Update CHANGELOG.md --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2bc9f19..f0e9cb1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Important Announcements ### Added +- Add support for flac codec +- Add codecs to .mov files +- Add background radius to text clips ### Changed @@ -21,8 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix ffmpeg reading crash when invalid metadata (see pr #2311) -- Add support for flac codec -- Add codecs to .mov files ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) From bee6c148f808480f9835beda32700ea0803c1707 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 17:09:39 +0100 Subject: [PATCH 50/80] fix linting --- moviepy/tools.py | 2 +- moviepy/video/io/ffmpeg_writer.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/moviepy/tools.py b/moviepy/tools.py index b42afd0e6..2e41fe501 100644 --- a/moviepy/tools.py +++ b/moviepy/tools.py @@ -156,7 +156,7 @@ def deprecated_func(*args, **kwargs): "mp3": {"type": "audio", "codec": ["libmp3lame"]}, "wav": {"type": "audio", "codec": ["pcm_s16le", "pcm_s24le", "pcm_s32le"]}, "m4a": {"type": "audio", "codec": ["libfdk_aac"]}, - "flac":{"type": "audio", "codec": ["flac"]} + "flac": {"type": "audio", "codec": ["flac"]}, } for ext in ["jpg", "jpeg", "png", "bmp", "tiff"]: diff --git a/moviepy/video/io/ffmpeg_writer.py b/moviepy/video/io/ffmpeg_writer.py index 7709b5f9d..8ffdb60b6 100644 --- a/moviepy/video/io/ffmpeg_writer.py +++ b/moviepy/video/io/ffmpeg_writer.py @@ -121,9 +121,9 @@ def __init__( if audiofile is not None: cmd.extend(["-i", audiofile, "-acodec", "copy"]) - if (codec == "h264_nvenc") : + if codec == "h264_nvenc": cmd.extend(["-c:v", codec]) - else : + else: cmd.extend(["-vcodec", codec]) cmd.extend(["-preset", preset]) @@ -141,7 +141,11 @@ def __init__( if codec == "libvpx" and with_mask: cmd.extend(["-pix_fmt", "yuva420p"]) cmd.extend(["-auto-alt-ref", "0"]) - elif (codec == "libx264" or codec == "h264_nvenc") and (size[0] % 2 == 0) and (size[1] % 2 == 0): + elif ( + (codec == "libx264" or codec == "h264_nvenc") + and (size[0] % 2 == 0) + and (size[1] % 2 == 0) + ): cmd.extend(["-pix_fmt", "yuva420p"]) cmd.extend([ffmpeg_escape_filename(filename)]) From 5ed9c0ff92530cea8fe4722642c39d7b9c0ca59d Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 17:14:54 +0100 Subject: [PATCH 51/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e9cb1d7..03e9bd722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix ffmpeg reading crash when invalid metadata (see pr #2311) +- Fix GPU h264_nvenc encoding not working. ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) From 456ebe983f9c251d021ebd7210eda3bc81588d4a Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 19:26:00 +0100 Subject: [PATCH 52/80] linting --- examples/soundtrack.py | 1 + moviepy/Clip.py | 1 + tests/test_VideoFileClip.py | 1 + 3 files changed, 3 insertions(+) diff --git a/examples/soundtrack.py b/examples/soundtrack.py index 48adbb998..c2efd8c05 100644 --- a/examples/soundtrack.py +++ b/examples/soundtrack.py @@ -1,4 +1,5 @@ """A simple test script on how to put a soundtrack to a movie.""" + from moviepy import * diff --git a/moviepy/Clip.py b/moviepy/Clip.py index 5c2d88b2d..63857296f 100644 --- a/moviepy/Clip.py +++ b/moviepy/Clip.py @@ -1,6 +1,7 @@ """Implements the central object of MoviePy, the Clip, and all the methods that are common to the two subclasses of Clip, VideoClip and AudioClip. """ + import copy as _copy from functools import reduce from numbers import Real diff --git a/tests/test_VideoFileClip.py b/tests/test_VideoFileClip.py index e92f78e04..bd8f996e1 100644 --- a/tests/test_VideoFileClip.py +++ b/tests/test_VideoFileClip.py @@ -1,4 +1,5 @@ """Video file clip tests meant to be run with pytest.""" + import copy import os From 618d066e1eff850eb71a31e2abdf1f210cf00db8 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 19:31:18 +0100 Subject: [PATCH 53/80] linting --- moviepy/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/moviepy/__init__.py b/moviepy/__init__.py index 2b9baa8d7..2d4d60c74 100644 --- a/moviepy/__init__.py +++ b/moviepy/__init__.py @@ -1,6 +1,7 @@ """Imports everything that you need from the MoviePy submodules so that every thing can be directly imported with ``from moviepy import *``. """ + from moviepy.audio import fx as afx from moviepy.audio.AudioClip import ( AudioArrayClip, From 685137dcf9cc7be00c7d36253427dc9a123374c1 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 19:53:34 +0100 Subject: [PATCH 54/80] linting --- moviepy/decorators.py | 1 - 1 file changed, 1 deletion(-) diff --git a/moviepy/decorators.py b/moviepy/decorators.py index 812803b70..3bdd13db3 100644 --- a/moviepy/decorators.py +++ b/moviepy/decorators.py @@ -126,7 +126,6 @@ def add_mask_if_none(func, clip, *args, **kwargs): def use_clip_fps_by_default(func): """Will use ``clip.fps`` if no ``fps=...`` is provided in **kwargs**.""" - argnames = inspect.getfullargspec(func).args[1:] def find_fps(clip, fps): From f7eeabb0ae0c279e412f667e0c94e696519a7c40 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Fri, 10 Jan 2025 20:07:22 +0100 Subject: [PATCH 55/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e9bd722..6319cb9d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix ffmpeg reading crash when invalid metadata (see pr #2311) - Fix GPU h264_nvenc encoding not working. +- Improve perfs of decorator by pre-computing arguments ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) From f9bb7dadb457af80de32bca9dafcc488fc06f0ee Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Fri, 10 Jan 2025 20:15:53 +0100 Subject: [PATCH 56/80] Remove trailing print --- moviepy/video/io/ffmpeg_reader.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index ea552f200..8f6835aa6 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -114,8 +114,6 @@ def initialize(self, start_time=0): elif codec_name == "vp8": i_arg = ["-c:v", "libvpx"] + i_arg - print(self.infos) - cmd = ( [FFMPEG_BINARY] + i_arg @@ -136,8 +134,6 @@ def initialize(self, start_time=0): ] ) - print(" ".join(cmd)) - popen_params = cross_platform_popen_params( { "bufsize": self.bufsize, From 5e8649c94d8594a21d98b6386fb7108a558f24d9 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 11 Jan 2025 00:08:04 +0100 Subject: [PATCH 57/80] Update list of maintainers --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e263a1fb..e7733917d 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,17 @@ To ask for help or simply discuss usage and examples, use [our Reddit channel](h # Maintainers +## Active maintainers - [Zulko](https://github.com/Zulko) (owner) - [@osaajani](https://github.com/OsaAjani) led the development of v2 ([MR](https://github.com/Zulko/moviepy/pull/2024)) - [@tburrows13](https://github.com/tburrows13) +- [@keikoro](https://github.com/keikoro) + +## Past maintainers and thanks - [@mgaitan](https://github.com/mgaitan) - [@earney](https://github.com/earney) - [@mbeacom](https://github.com/mbeacom) - [@overdrivr](https://github.com/overdrivr) -- [@keikoro](https://github.com/keikoro) - [@ryanfox](https://github.com/ryanfox) - [@mondeja](https://github.com/mondeja) From e9559d8c3ebf12bb6986a2ba26a85b04dd335dd6 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 11 Jan 2025 00:49:15 +0100 Subject: [PATCH 58/80] Bump pillow to allow for pillow 11 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a553168a4..ff96c12d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "numpy>=1.25.0", "proglog<=1.0.0", "python-dotenv>=0.10", - "pillow>=9.2.0,<11.0", + "pillow>=9.2.0,<12.0", ] [project.optional-dependencies] From a86f3a3e7688b0cdac5846886e9c1eef8ad43a85 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Sat, 11 Jan 2025 00:51:23 +0100 Subject: [PATCH 59/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6319cb9d0..b3575a66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for flac codec - Add codecs to .mov files - Add background radius to text clips +- Support pillow 11 ### Changed From ac2ef2df522e5f9bc73faa23d1ddae5cf7307a17 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 11 Jan 2025 17:38:17 +0100 Subject: [PATCH 60/80] Add exception on outside of boundaries subclip --- CHANGELOG.md | 1 + moviepy/Clip.py | 8 ++++++++ tests/test_Clip.py | 1 + tests/test_VideoClip.py | 13 +++++++------ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6319cb9d0..2bad3dea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add background radius to text clips ### Changed +- Subclipping outside of clip boundaries now raise an exception ### Deprecated diff --git a/moviepy/Clip.py b/moviepy/Clip.py index 871ed8e3e..dfd8bc502 100644 --- a/moviepy/Clip.py +++ b/moviepy/Clip.py @@ -402,6 +402,7 @@ def subclipped(self, start_time=0, end_time=None): + "should be smaller than the clip's " + "duration (%.02f)." % self.duration ) + new_clip = self.time_transform(lambda t: t + start_time, apply_to=[]) @@ -422,6 +423,13 @@ def subclipped(self, start_time=0, end_time=None): end_time = self.duration + end_time if end_time is not None: + if (self.duration is not None) and (end_time > self.duration): + raise ValueError( + "end_time (%.02f) " % end_time + + "should be smaller or equal to the clip's " + + "duration (%.02f)." % self.duration + ) + new_clip.duration = end_time - start_time new_clip.end = new_clip.start + new_clip.duration diff --git a/tests/test_Clip.py b/tests/test_Clip.py index 397f228bb..0dcabf841 100644 --- a/tests/test_Clip.py +++ b/tests/test_Clip.py @@ -184,6 +184,7 @@ def test_clip_copy(copy_func): (3, 3, None, ValueError), # start_time == duration (3, 1, -1, 1), # negative end_time (None, 1, -1, ValueError), # negative end_time for clip without duration + (1, 0, 2, ValueError), # end_time after video end should raise exception ), ) def test_clip_subclip(duration, start_time, end_time, expected_duration): diff --git a/tests/test_VideoClip.py b/tests/test_VideoClip.py index 53c04817b..238133f4f 100644 --- a/tests/test_VideoClip.py +++ b/tests/test_VideoClip.py @@ -389,16 +389,17 @@ def test_afterimage(util): def test_add(): clip = VideoFileClip("media/fire2.mp4") - new_clip = clip[0:1] + clip[2:3.2] - assert new_clip.duration == 2.2 - assert np.array_equal(new_clip[1.1], clip[2.1]) + print(clip.duration) + new_clip = clip[0:1] + clip[1.5:2] + assert new_clip.duration == 1.5 + assert np.array_equal(new_clip[1.1], clip[1.6]) def test_slice_tuples(): clip = VideoFileClip("media/fire2.mp4") - new_clip = clip[0:1, 2:3.2] - assert new_clip.duration == 2.2 - assert np.array_equal(new_clip[1.1], clip[2.1]) + new_clip = clip[0:1, 1.5:2] + assert new_clip.duration == 1.5 + assert np.array_equal(new_clip[1.1], clip[1.6]) def test_slice_mirror(): From 1ab989ae410df05d1b3650abfbc7960db741ac17 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 11 Jan 2025 22:22:30 +0100 Subject: [PATCH 61/80] Add some tolerance to account for rounding errors --- moviepy/Clip.py | 6 +++--- moviepy/video/io/ffmpeg_reader.py | 12 ++++++------ tests/test_VideoClip.py | 1 - tests/test_issues.py | 7 +++---- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/moviepy/Clip.py b/moviepy/Clip.py index dfd8bc502..258fcd193 100644 --- a/moviepy/Clip.py +++ b/moviepy/Clip.py @@ -402,7 +402,6 @@ def subclipped(self, start_time=0, end_time=None): + "should be smaller than the clip's " + "duration (%.02f)." % self.duration ) - new_clip = self.time_transform(lambda t: t + start_time, apply_to=[]) @@ -423,13 +422,14 @@ def subclipped(self, start_time=0, end_time=None): end_time = self.duration + end_time if end_time is not None: - if (self.duration is not None) and (end_time > self.duration): + # Allow a slight tolerance to account for rounding errors + if (self.duration is not None) and (end_time - self.duration > 0.00000001): raise ValueError( "end_time (%.02f) " % end_time + "should be smaller or equal to the clip's " + "duration (%.02f)." % self.duration ) - + new_clip.duration = end_time - start_time new_clip.end = new_clip.start + new_clip.duration diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 41de84142..8f6835aa6 100644 --- a/moviepy/video/io/ffmpeg_reader.py +++ b/moviepy/video/io/ffmpeg_reader.py @@ -476,12 +476,12 @@ def parse(self): # for default streams, set their numbers globally, so it's # easy to get without iterating all if self._current_stream["default"]: - self.result[f"default_{stream_type_lower}_input_number"] = ( - input_number - ) - self.result[f"default_{stream_type_lower}_stream_number"] = ( - stream_number - ) + self.result[ + f"default_{stream_type_lower}_input_number" + ] = input_number + self.result[ + f"default_{stream_type_lower}_stream_number" + ] = stream_number # exit chapter if self._current_chapter: diff --git a/tests/test_VideoClip.py b/tests/test_VideoClip.py index 238133f4f..6cd61bb76 100644 --- a/tests/test_VideoClip.py +++ b/tests/test_VideoClip.py @@ -389,7 +389,6 @@ def test_afterimage(util): def test_add(): clip = VideoFileClip("media/fire2.mp4") - print(clip.duration) new_clip = clip[0:1] + clip[1.5:2] assert new_clip.duration == 1.5 assert np.array_equal(new_clip[1.1], clip[1.6]) diff --git a/tests/test_issues.py b/tests/test_issues.py index ae2d6acda..79a086df9 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -303,9 +303,8 @@ def test_issue_470(util): audio_clip = AudioFileClip("media/crunching.mp3") # end_time is out of bounds - subclip = audio_clip.subclipped(start_time=6, end_time=9) - - with pytest.raises(IOError): + with pytest.raises(ValueError): + subclip = audio_clip.subclipped(start_time=6, end_time=9) subclip.write_audiofile(wav_filename, write_logfile=True) # but this one should work.. @@ -334,7 +333,7 @@ def test_issue_636(): def test_issue_655(): video_file = "media/fire2.mp4" - for subclip in [(0, 2), (1, 2), (2, 3)]: + for subclip in [(0, 2), (1, 2), (2, 2.10)]: with VideoFileClip(video_file) as v: with v.subclipped(1, 2) as _: pass From 5f1173e7d55727eb8a7358c5ea7d51ec7a2a48ce Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sat, 11 Jan 2025 22:56:41 +0100 Subject: [PATCH 62/80] Update issue template --- .github/ISSUE_TEMPLATE/bug-report.md | 15 +++++++++++++-- .github/ISSUE_TEMPLATE/question.md | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 5935156ab..dfc68b31b 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -14,6 +14,8 @@ You can format code by putting ``` (that's 3 backticks) on a line by itself at t from moviepy import * clip = ColorClip((600, 400), color=(255, 100, 0), duration=2) ``` + +Please, include runnable working example of code that can trigger the bug so we can easily reproduce and investigate the bug. --> @@ -23,8 +25,17 @@ clip = ColorClip((600, 400), color=(255, 100, 0), duration=2) #### Actual Behavior -#### Steps to Reproduce the Problem - +#### Steps and code to Reproduce the Problem + + + +#### Used medias + #### Specifications diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 19eb1e4bd..7501d79ed 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,6 +1,6 @@ --- name: Question -about: Ask a question about how to use MoviePy +about: Ask a question about an unexpected behavior of MoviePy title: '' labels: question assignees: '' @@ -8,7 +8,7 @@ assignees: '' --- - Subclipping outside of clip boundaries now raise an exception From 0b74192dcdac87b2e58263caa1815734171e014b Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Wed, 22 Jan 2025 23:22:32 +0100 Subject: [PATCH 78/80] Fix Freeze effect removing original start and end parameters --- moviepy/Clip.py | 18 ++++++++++++++++++ moviepy/video/fx/Freeze.py | 14 +++++++++++++- moviepy/video/io/ffmpeg_tools.py | 3 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/moviepy/Clip.py b/moviepy/Clip.py index e931f5551..92fbfddf0 100644 --- a/moviepy/Clip.py +++ b/moviepy/Clip.py @@ -218,6 +218,15 @@ def with_start(self, t, change_end=True): These changes are also applied to the ``audio`` and ``mask`` clips of the current clip, if they exist. + note:: + The start and end attribute of a clip define when a clip will start + playing when used in a composite video clip, not the start time of + the clip itself. + + i.e: with_start(10) mean the clip will still start at his first frame, + but if used in a composite video clip it will only start to show at + 10 seconds. + Parameters ---------- @@ -248,6 +257,15 @@ def with_end(self, t): (hour, min, sec), or as a string: '01:03:05.35'. Also sets the duration of the mask and audio, if any, of the returned clip. + note:: + The start and end attribute of a clip define when a clip will start + playing when used in a composite video clip, not the start time of + the clip itself. + + i.e: with_start(10) mean the clip will still start at his first frame, + but if used in a composite video clip it will only start to show at + 10 seconds. + Parameters ---------- diff --git a/moviepy/video/fx/Freeze.py b/moviepy/video/fx/Freeze.py index 40dc3c9f9..532f2558d 100644 --- a/moviepy/video/fx/Freeze.py +++ b/moviepy/video/fx/Freeze.py @@ -15,12 +15,16 @@ class Freeze(Effect): With ``total_duration`` you can specify the total duration of the clip and the freeze (i.e. the duration of the freeze is automatically computed). One of them must be provided. + + With ``update_start_end`` you can define if the effect must preserve + and/or update start and end properties of the original clip """ t: float = 0 freeze_duration: float = None total_duration: float = None padding_end: float = 0 + update_start_end: bool = True def apply(self, clip: Clip) -> Clip: """Apply the effect to the clip.""" @@ -40,4 +44,12 @@ def apply(self, clip: Clip) -> Clip: before = [clip[: self.t]] if (self.t != 0) else [] freeze = [clip.to_ImageClip(self.t).with_duration(self.freeze_duration)] after = [clip[self.t :]] if (self.t != clip.duration) else [] - return concatenate_videoclips(before + freeze + after) + + new_clip = concatenate_videoclips(before + freeze + after) + if self.update_start_end: + if clip.start is not None: + new_clip = new_clip.with_start(clip.start) + if clip.end is not None: + new_clip = new_clip.with_end(clip.end + self.freeze_duration) + + return new_clip diff --git a/moviepy/video/io/ffmpeg_tools.py b/moviepy/video/io/ffmpeg_tools.py index c154d9b59..8cb5351c7 100644 --- a/moviepy/video/io/ffmpeg_tools.py +++ b/moviepy/video/io/ffmpeg_tools.py @@ -240,7 +240,8 @@ def ffmpeg_version(): cmd = [ FFMPEG_BINARY, "-version", - "-v", "quiet", + "-v", + "quiet", ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) From 030c147722c201abfdfb204abb326438c1bea795 Mon Sep 17 00:00:00 2001 From: Pierre-Lin Bonnemaison Date: Wed, 22 Jan 2025 23:31:56 +0100 Subject: [PATCH 79/80] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb9b4b27..76afb4e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Subclipping outside of clip boundaries now raise an exception +- Freeze effect no longer remove start and end ### Deprecated From 0ca2fea675230030f496de1d4d49556074897089 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Thu, 23 Jan 2025 17:39:08 +0100 Subject: [PATCH 80/80] Add support for default pillow font --- CHANGELOG.md | 1 + moviepy/video/VideoClip.py | 55 +++++++++++++++++++++++--------------- tests/test_TextClip.py | 7 +++++ 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cc0398b..6baefdc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add codecs to .mov files - Add background radius to text clips - Support pillow 11 +- Add support for Pillow default font on textclip ### Changed diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index e64d8a37f..96a66578a 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1450,7 +1450,8 @@ class TextClip(ImageClip): ---------- font - Path to the font to use. Must be an OpenType font. + Path to the font to use. Must be an OpenType font. If set to None + (default) will use Pillow default font text A string of the text to write. Can be replaced by argument @@ -1549,7 +1550,7 @@ class TextClip(ImageClip): @convert_path_to_string("filename") def __init__( self, - font, + font=None, text=None, filename=None, font_size=None, @@ -1567,12 +1568,13 @@ def __init__( transparent=True, duration=None, ): - try: - _ = ImageFont.truetype(font) - except Exception as e: - raise ValueError( - "Invalid font {}, pillow failed to use it with error {}".format(font, e) - ) + if font is not None: + try: + _ = ImageFont.truetype(font) + except Exception as e: + raise ValueError( + "Invalid font {}, pillow failed to use it with error {}".format(font, e) + ) if filename: with open(filename, "r") as file: @@ -1620,30 +1622,30 @@ def __init__( allow_break=True, ) - if img_height is None: - img_height = self.__find_text_size( + # Add line breaks whenever needed + text = "\n".join( + self.__break_text( + width=img_width, text=text, font=font, font_size=font_size, stroke_width=stroke_width, align=text_align, spacing=interline, - max_width=img_width, - allow_break=True, - )[1] + ) + ) - # Add line breaks whenever needed - text = "\n".join( - self.__break_text( - width=img_width, + if img_height is None: + img_height = self.__find_text_size( text=text, font=font, font_size=font_size, stroke_width=stroke_width, align=text_align, spacing=interline, - ) - ) + max_width=img_width, + allow_break=True, + )[1] elif method == "label": if font_size is None and img_width is None: @@ -1693,7 +1695,10 @@ def __init__( bg_color = (0, 0, 0, 0) img = Image.new(img_mode, (img_width, img_height), color=bg_color) - pil_font = ImageFont.truetype(font, font_size) + if font: + pil_font = ImageFont.truetype(font, font_size) + else: + pil_font = ImageFont.load_default(font_size) draw = ImageDraw.Draw(img) # Dont need allow break here, because we already breaked in caption @@ -1760,7 +1765,10 @@ def __break_text( ) -> List[str]: """Break text to never overflow a width""" img = Image.new("RGB", (1, 1)) - font_pil = ImageFont.truetype(font, font_size) + if font: + font_pil = ImageFont.truetype(font, font_size) + else: + font_pil = ImageFont.load_default(font_size) draw = ImageDraw.Draw(img) lines = [] @@ -1843,7 +1851,10 @@ def __find_text_size( ``real_font_size + (stroke_width * 2) + (lines - 1) * height`` """ img = Image.new("RGB", (1, 1)) - font_pil = ImageFont.truetype(font, font_size) + if font: + font_pil = ImageFont.truetype(font, font_size) + else: + font_pil = ImageFont.load_default(font_size) ascent, descent = font_pil.getmetrics() real_font_size = ascent + descent draw = ImageDraw.Draw(img) diff --git a/tests/test_TextClip.py b/tests/test_TextClip.py index 1d75c1b81..7f87d09ee 100644 --- a/tests/test_TextClip.py +++ b/tests/test_TextClip.py @@ -133,5 +133,12 @@ def test_label_autosizing(util): assert not np.allclose(last_three_columns, [0, 0, 0], rtol=0.01) +def test_no_font(util): + # Try make a clip with default font + clip = TextClip(text="Hello world !", font_size=20, color="white") + clip.show(1) + assert clip.size[0] > 10 + + if __name__ == "__main__": pytest.main()