From 1a906c9921794358aa2e0dd1632a1426263ae9b0 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 12 Jan 2025 14:51:10 +0100 Subject: [PATCH 1/8] Fix text clip cutting text parts --- moviepy/video/VideoClip.py | 352 +++++++++++++++--------------- moviepy/video/io/ffmpeg_reader.py | 12 +- 2 files changed, 179 insertions(+), 185 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 2b4694920..2fa0b6dac 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1496,10 +1496,15 @@ class TextClip(ImageClip): Width of the stroke, in pixels. Must be an int. method - Either 'label' (default, the picture will be autosized so as to fit - exactly the size) or 'caption' (the text will be drawn in a picture - with fixed size provided with the ``size`` argument). If `caption`, - the text will be wrapped automagically. + Either : + - 'label' (default), the picture will be autosized so as to fit the text + either by auto-computing font size if width is provided or auto-computing + width and eight if font size is defined + + - 'caption' the text will be drawn in a picture with fixed size provided + with the ``size`` argument. The text will be wrapped automagically, + either by auto-computing font size if width is provided or adding + line break when necesarry if font size is defined text_align center | left | right. Text align similar to css. Default to ``left``. @@ -1521,11 +1526,6 @@ 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") @@ -1548,141 +1548,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 - ) -> List[str]: - """Break text to never overflow a width""" - img = Image.new("RGB", (1, 1)) - font_pil = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) - - lines = [] - current_line = "" - words = text.split(" ") - for word in words: - temp_line = current_line + " " + word if current_line else word - temp_left, temp_top, temp_right, temp_bottom = draw.multiline_textbbox( - (0, 0), - temp_line, - font=font_pil, - spacing=spacing, - align=align, - stroke_width=stroke_width, - ) - temp_width = temp_right - temp_left - - if temp_width <= width: - current_line = temp_line - else: - lines.append(current_line) - current_line = word - - if current_line: - lines.append(current_line) - - return lines - - def find_text_size( - text, - font, - font_size, - stroke_width, - align, - spacing, - max_width=None, - allow_break=False, - ) -> tuple[int, int]: - """Find dimensions a text will occupy, return a tuple (width, height)""" - img = Image.new("RGB", (1, 1)) - font_pil = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) - - if max_width is None or not allow_break: - left, top, right, bottom = draw.multiline_textbbox( - (0, 0), - text, - font=font_pil, - spacing=spacing, - align=align, - stroke_width=stroke_width, - anchor="lm", - ) - - return (int(right - left), int(bottom - top)) - - lines = break_text( - width=max_width, - text=text, - font=font, - font_size=font_size, - stroke_width=stroke_width, - align=align, - spacing=spacing, - ) - - left, top, right, bottom = draw.multiline_textbbox( - (0, 0), - "\n".join(lines), - font=font_pil, - spacing=spacing, - align=align, - stroke_width=stroke_width, - anchor="lm", - ) - - return (int(right - left), int(bottom - top)) - - def find_optimum_font_size( - text, - font, - stroke_width, - align, - spacing, - width, - height=None, - allow_break=False, - ): - """Find the best font size to fit as optimally as possible""" - max_font_size = width - min_font_size = 1 - - # Try find best size using bisection - while min_font_size < max_font_size: - avg_font_size = int((max_font_size + min_font_size) // 2) - text_width, text_height = find_text_size( - text, - font, - avg_font_size, - stroke_width, - align, - spacing, - max_width=width, - allow_break=allow_break, - ) - - if text_width <= width and (height is None or text_height <= height): - min_font_size = avg_font_size + 1 - else: - max_font_size = avg_font_size - 1 - - # Check if the last font size tested fits within the given width and height - text_width, text_height = find_text_size( - text, - font, - min_font_size, - stroke_width, - align, - spacing, - max_width=width, - allow_break=allow_break, - ) - if text_width <= width and (height is None or text_height <= height): - return min_font_size - else: - return min_font_size - 1 - try: _ = ImageFont.truetype(font) except Exception as e: @@ -1697,6 +1563,21 @@ def find_optimum_font_size( if text is None: raise ValueError("No text nor filename provided") + if method not in ["caption", "label"]: + raise ValueError("Method must be either `caption` or `label`.") + + # Compute the margin and apply it + if len(margin) == 2: + left_margin = right_margin = int(margin[0] or 0) + top_margin = bottom_margin = int(margin[1] or 0) + elif len(margin) == 4: + left_margin = int(margin[0] or 0) + top_margin = int(margin[1] or 0) + right_margin = int(margin[2] or 0) + bottom_margin = int(margin[3] or 0) + else: + raise ValueError("Margin must be a tuple of either 2 or 4 elements.") + # Compute all img and text sizes if some are missing img_width, img_height = size @@ -1710,7 +1591,7 @@ def find_optimum_font_size( ) if font_size is None: - font_size = find_optimum_font_size( + font_size = self.__find_optimum_font_size( text=text, font=font, stroke_width=stroke_width, @@ -1722,7 +1603,7 @@ def find_optimum_font_size( ) if img_height is None: - img_height = find_text_size( + img_height = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1735,7 +1616,7 @@ def find_optimum_font_size( # Add line breaks whenever needed text = "\n".join( - break_text( + self.__break_text( width=img_width, text=text, font=font, @@ -1753,7 +1634,7 @@ def find_optimum_font_size( ) if font_size is None: - font_size = find_optimum_font_size( + font_size = self.__find_optimum_font_size( text=text, font=font, stroke_width=stroke_width, @@ -1764,7 +1645,7 @@ def find_optimum_font_size( ) if img_width is None: - img_width = find_text_size( + img_width = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1774,7 +1655,7 @@ def find_optimum_font_size( )[0] if img_height is None: - img_height = find_text_size( + img_height = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1784,21 +1665,8 @@ def find_optimum_font_size( max_width=img_width, )[1] - else: - raise ValueError("Method must be either `caption` or `label`.") - - # Compute the margin and apply it - if len(margin) == 2: - left_margin = right_margin = int(margin[0] or 0) - top_margin = bottom_margin = int(margin[1] or 0) - elif len(margin) == 4: - left_margin = int(margin[0] or 0) - top_margin = int(margin[1] or 0) - right_margin = int(margin[2] or 0) - bottom_margin = int(margin[3] or 0) - else: - raise ValueError("Margin must be a tuple of either 2 or 4 elements.") - + # In image size computing include font descent to height to avoid + # clipping text img_width += left_margin + right_margin img_height += top_margin + bottom_margin @@ -1808,23 +1676,12 @@ def find_optimum_font_size( if bg_color is None and transparent: bg_color = (0, 0, 0, 0) - 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) + 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( + text_width, text_height = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1882,6 +1739,143 @@ def find_optimum_font_size( self.color = color self.stroke_color = stroke_color + def __break_text( + self, width, text, font, font_size, stroke_width, align, spacing + ) -> List[str]: + """Break text to never overflow a width""" + img = Image.new("RGB", (1, 1)) + font_pil = ImageFont.truetype(font, font_size) + draw = ImageDraw.Draw(img) + + lines = [] + current_line = "" + words = text.split(" ") + for word in words: + temp_line = current_line + " " + word if current_line else word + temp_left, temp_top, temp_right, temp_bottom = draw.multiline_textbbox( + (0, 0), + temp_line, + font=font_pil, + spacing=spacing, + align=align, + stroke_width=stroke_width, + ) + temp_width = temp_right - temp_left + + if temp_width <= width: + current_line = temp_line + else: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines + + def __find_text_size( + self, + text, + font, + font_size, + stroke_width, + align, + spacing, + max_width=None, + allow_break=False, + ) -> tuple[int, int]: + """Find dimensions a text will occupy, return a tuple (width, height)""" + img = Image.new("RGB", (1, 1)) + font_pil = ImageFont.truetype(font, font_size) + ascent, descent = font_pil.getmetrics() + draw = ImageDraw.Draw(img) + + if max_width is None or not allow_break: + left, top, right, bottom = draw.multiline_textbbox( + (0, 0), + text, + font=font_pil, + spacing=spacing, + align=align, + stroke_width=stroke_width, + anchor="lm", + ) + + return (int(right - left), int(bottom - top + descent)) + + lines = self.__break_text( + width=max_width, + text=text, + font=font, + font_size=font_size, + stroke_width=stroke_width, + align=align, + spacing=spacing, + ) + + left, top, right, bottom = draw.multiline_textbbox( + (0, 0), + "\n".join(lines), + font=font_pil, + spacing=spacing, + align=align, + stroke_width=stroke_width, + anchor="lm", + ) + + # Add descent to text size to avoid bottom of text clipping + return (int(right - left), int(bottom - top + descent)) + + def __find_optimum_font_size( + self, + text, + font, + stroke_width, + align, + spacing, + width, + height=None, + allow_break=False, + ): + """Find the best font size to fit as optimally as possible""" + max_font_size = width + min_font_size = 1 + + # Try find best size using bisection + while min_font_size < max_font_size: + avg_font_size = int((max_font_size + min_font_size) // 2) + text_width, text_height = self.__find_text_size( + text, + font, + avg_font_size, + stroke_width, + align, + spacing, + max_width=width, + allow_break=allow_break, + ) + + if text_width <= width and (height is None or text_height <= height): + min_font_size = avg_font_size + 1 + else: + max_font_size = avg_font_size - 1 + + # Check if the last font size tested fits within the given width and height + text_width, text_height = self.__find_text_size( + text, + font, + min_font_size, + stroke_width, + align, + spacing, + max_width=width, + allow_break=allow_break, + ) + if text_width <= width and (height is None or text_height <= height): + return min_font_size + else: + return min_font_size - 1 + class BitmapClip(VideoClip): """Clip made of color bitmaps. Mainly designed for testing purposes.""" 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: From afe591930bf8972b8dd507e47f90494335715bb8 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 12 Jan 2025 18:52:31 +0100 Subject: [PATCH 2/8] First step, a really reliable way to compute text size, at least for label with font size, but always generate an image for max possible size for the font, not real size --- moviepy/video/VideoClip.py | 44 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 2fa0b6dac..8d2fc3131 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1503,7 +1503,7 @@ class TextClip(ImageClip): - 'caption' the text will be drawn in a picture with fixed size provided with the ``size`` argument. The text will be wrapped automagically, - either by auto-computing font size if width is provided or adding + either by auto-computing font size if width and height are provided or adding line break when necesarry if font size is defined text_align @@ -1665,8 +1665,6 @@ def __init__( max_width=img_width, )[1] - # In image size computing include font descent to height to avoid - # clipping text img_width += left_margin + right_margin img_height += top_margin + bottom_margin @@ -1697,16 +1695,12 @@ def __init__( elif horizontal_align == "center": x = (img_width - left_margin - right_margin - text_width) / 2 - x += left_margin - y = 0 if vertical_align == "bottom": y = img_height - text_height - top_margin - bottom_margin elif vertical_align == "center": y = (img_height - top_margin - bottom_margin - text_height) / 2 - y += top_margin - # So, pillow multiline support is horrible, in particular multiline_text # and multiline_textbbox are not intuitive at all. They cannot use left # top (see https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html) @@ -1715,7 +1709,19 @@ def __init__( # text. That mean our Y is actually not from 0 for top, but need to be # increment by half our text height, since we have to reference from # middle line. - y += text_height / 2 + (ascent, descent) = pil_font.getmetrics() + real_font_size = ascent + descent + print("Font size", font_size) + print("Real font size", real_font_size) + print("Ascent & Descent", (ascent, descent)) + print("Height", text_height) + y += real_font_size - descent + + # Add margins and stroke size to start point + y += top_margin + x += left_margin + y += stroke_width + x += stroke_width draw.multiline_text( xy=(x, y), @@ -1726,7 +1732,7 @@ def __init__( align=text_align, stroke_width=stroke_width, stroke_fill=stroke_color, - anchor="lm", + anchor="ls", ) # We just need the image as a numpy array @@ -1788,9 +1794,16 @@ def __find_text_size( img = Image.new("RGB", (1, 1)) font_pil = ImageFont.truetype(font, font_size) ascent, descent = font_pil.getmetrics() + real_font_size = ascent + descent draw = ImageDraw.Draw(img) + # Compute individual line height with spaces using pillow internal method + line_height = draw._multiline_spacing(font_pil, spacing, stroke_width) if max_width is None or not allow_break: + line_breaks = text.count('\n') + nb_lines = line_breaks + 1 + total_text_height = nb_lines * line_height + left, top, right, bottom = draw.multiline_textbbox( (0, 0), text, @@ -1798,10 +1811,11 @@ def __find_text_size( spacing=spacing, align=align, stroke_width=stroke_width, - anchor="lm", + anchor="ls", ) - return (int(right - left), int(bottom - top + descent)) + # Multiline textbbox is not reliable for height + return (int(right - left), int(spacing + total_text_height)) lines = self.__break_text( width=max_width, @@ -1813,6 +1827,10 @@ def __find_text_size( spacing=spacing, ) + nb_lines = len(lines) + line_breaks = nb_lines - 1 + total_text_height = nb_lines * real_font_size + left, top, right, bottom = draw.multiline_textbbox( (0, 0), "\n".join(lines), @@ -1820,11 +1838,11 @@ def __find_text_size( spacing=spacing, align=align, stroke_width=stroke_width, - anchor="lm", + anchor="ls", ) # Add descent to text size to avoid bottom of text clipping - return (int(right - left), int(bottom - top + descent)) + return (int(right - left), int(spacing + total_text_height)) def __find_optimum_font_size( self, From c0db7348abb8c61ccf8a78a792095844eebeee1d Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Sun, 12 Jan 2025 19:34:51 +0100 Subject: [PATCH 3/8] remove print --- moviepy/video/VideoClip.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 8d2fc3131..69d375d43 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1711,10 +1711,6 @@ def __init__( # middle line. (ascent, descent) = pil_font.getmetrics() real_font_size = ascent + descent - print("Font size", font_size) - print("Real font size", real_font_size) - print("Ascent & Descent", (ascent, descent)) - print("Height", text_height) y += real_font_size - descent # Add margins and stroke size to start point From 3c31cd0c8d52e8b48c1dcbd5c612eca8e86b5c09 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 14 Jan 2025 01:35:02 +0100 Subject: [PATCH 4/8] Finally consistent text clip that will always have the exact maximum size for some text --- moviepy/video/VideoClip.py | 108 +++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 40 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 69d375d43..066838ae2 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1704,14 +1704,12 @@ def __init__( # So, pillow multiline support is horrible, in particular multiline_text # and multiline_textbbox are not intuitive at all. They cannot use left # top (see https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html) - # as anchor, so we always have to use left middle instead. Else we would + # as anchor, so we always have to use left baseline instead. Else we would # always have a useless margin (the diff between ascender and top) on any # text. That mean our Y is actually not from 0 for top, but need to be - # increment by half our text height, since we have to reference from - # middle line. - (ascent, descent) = pil_font.getmetrics() - real_font_size = ascent + descent - y += real_font_size - descent + # increment by ascent, since we have to reference from baseline. + (ascent, _) = pil_font.getmetrics() + y += ascent # Add margins and stroke size to start point y += top_margin @@ -1786,50 +1784,74 @@ def __find_text_size( max_width=None, allow_break=False, ) -> tuple[int, int]: - """Find dimensions a text will occupy, return a tuple (width, height)""" + """Find *real* dimensions a text will occupy, return a tuple (width, height) + + .. note:: + Text height calculation is quite complex due to how `Pillow` works. + When calculating line height, `Pillow` actually uses the letter ``A`` + as a reference height, adding the spacing and the stroke width. + However, ``A`` is a simple letter and does not account for ascent and + descent, such as in ``Ô``. + + This means each line will be considered as having a "standard" + height instead of the real maximum font size (``ascent + descent``). + + When drawing each line, `Pillow` will offset the new line by + ``standard height * number of previous lines``. + This mostly works, but if the spacing is not big enough, + lines will overlap if a letter with an ascent (e.g., ``d``) is above + a letter with a descent (e.g., ``p``). + + For our case, we use the baseline as the text anchor. This means that, + no matter what, we need to draw the absolute top of our first line at + ``0 + ascent + stroke_width`` to ensure the first pixel of any possible + letter is aligned with the top border of the image (ignoring any + additional margins, if needed). + + Therefore, our first line height will not start at ``0`` but at + ``ascent + stroke_width``, and we need to account for that. Each + subsequent line will then be drawn at + ``index * standard height`` from this point. The position of the last + line can be calculated as: + ``(total_lines - 1) * standard height``. + + Finally, as we use the baseline as the text anchor, we also need to + consider that the real size of the last line is not "standard" but + rather ``standard + descent + stroke_width``. + + To summarize, the real height of the text is: + ``initial padding + (lines - 1) * height + end padding`` + or: + ``(ascent + stroke_width) + (lines - 1) * height + (descent + stroke_width)`` + or: + ``real_font_size + (stroke_width * 2) + (lines - 1) * height`` + """ img = Image.new("RGB", (1, 1)) font_pil = ImageFont.truetype(font, font_size) ascent, descent = font_pil.getmetrics() real_font_size = ascent + descent draw = ImageDraw.Draw(img) + # Compute individual line height with spaces using pillow internal method line_height = draw._multiline_spacing(font_pil, spacing, stroke_width) - if max_width is None or not allow_break: - line_breaks = text.count('\n') - nb_lines = line_breaks + 1 - total_text_height = nb_lines * line_height - - left, top, right, bottom = draw.multiline_textbbox( - (0, 0), - text, - font=font_pil, - spacing=spacing, - align=align, + if max_width is not None and allow_break: + lines = self.__break_text( + width=max_width, + text=text, + font=font, + font_size=font_size, stroke_width=stroke_width, - anchor="ls", + align=align, + spacing=spacing, ) - # Multiline textbbox is not reliable for height - return (int(right - left), int(spacing + total_text_height)) - - lines = self.__break_text( - width=max_width, - text=text, - font=font, - font_size=font_size, - stroke_width=stroke_width, - align=align, - spacing=spacing, - ) - - nb_lines = len(lines) - line_breaks = nb_lines - 1 - total_text_height = nb_lines * real_font_size - + text = "\n".join(lines) + + # Use multiline textbbox to get width left, top, right, bottom = draw.multiline_textbbox( (0, 0), - "\n".join(lines), + text, font=font_pil, spacing=spacing, align=align, @@ -1837,8 +1859,13 @@ def __find_text_size( anchor="ls", ) - # Add descent to text size to avoid bottom of text clipping - return (int(right - left), int(spacing + total_text_height)) + # For height calculate manually as textbbox is not realiable + line_breaks = text.count('\n') + lines_height = line_breaks * line_height + paddings = real_font_size + stroke_width * 2 + + return (int(right - left), int(lines_height + paddings)) + def __find_optimum_font_size( self, @@ -1851,7 +1878,8 @@ def __find_optimum_font_size( height=None, allow_break=False, ): - """Find the best font size to fit as optimally as possible""" + """Find the best font size to fit as optimally as possible + in a box of some width and optionally height""" max_font_size = width min_font_size = 1 From 52f6e080f5ca540472199e369070bbad50574c2c Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 14 Jan 2025 01:46:16 +0100 Subject: [PATCH 5/8] add some note to the doc --- moviepy/video/VideoClip.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 066838ae2..04587c16c 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1526,6 +1526,24 @@ class TextClip(ImageClip): duration Duration of the clip + + .. note:: + + ** About final TextClip size ** + + The final TextClip size will be of the absolute maximum height possible + for the font and the number of line. It specifically mean that the final + height might be a bit bigger than the real text height, i.e, absolute + bottom pixel of text - absolute top pixel of text. + This is because in a font, some letter go above standard top line (e.g + letters with accents), and bellow standard baseline (e.g letters such as + p, y, g). + + This notion is knowned under the name ascent and descent meaning the + highest and lowest pixel above and below baseline + + If your first line dont have an "accent character" and your last line + dont have a "descent character", you'll have some "fat" arround """ @convert_path_to_string("filename") @@ -1701,10 +1719,8 @@ def __init__( elif vertical_align == "center": y = (img_height - top_margin - bottom_margin - text_height) / 2 - # So, pillow multiline support is horrible, in particular multiline_text - # and multiline_textbbox are not intuitive at all. They cannot use left - # top (see https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html) - # as anchor, so we always have to use left baseline instead. Else we would + # We use baseline as our anchor because it is predictable and reliable + # That mean we always have to use left baseline instead. Else we would # always have a useless margin (the diff between ascender and top) on any # text. That mean our Y is actually not from 0 for top, but need to be # increment by ascent, since we have to reference from baseline. From b81b4d0fe3961270e2fe6875c2eef2feb82825f1 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 14 Jan 2025 15:19:34 +0100 Subject: [PATCH 6/8] Add a test to check label autosizing works as expected --- tests/test_TextClip.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_TextClip.py b/tests/test_TextClip.py index 609f124c3..1b1f3d235 100644 --- a/tests/test_TextClip.py +++ b/tests/test_TextClip.py @@ -73,5 +73,41 @@ def test_no_text_nor_filename_arguments(method, util): ) +def test_label_autosizing(util): + # We test with the letters usually triggering cutting such a ypj and ÀÉÔ + text = "ÀÉÔÇjpgy\nÀÉÔÇjpgy" + + text_clip_margin = TextClip(util.FONT, method="label", font_size=40, text=text, color="red", bg_color="black", stroke_width=3, stroke_color="white", margin=(1, 1)).with_duration(1) + text_clip_no_margin = TextClip(util.FONT, method="label", font_size=40, text=text, color="red", bg_color="black", stroke_width=3, stroke_color="white").with_duration(1) + + margin_frame = text_clip_margin.get_frame(1) + no_margin_frame = text_clip_no_margin.get_frame(1) + + # The idea is, if autosizing work as expected, frame with 1px margin will + # have black color all around, where frame without margin will have white somewhere + first_row, last_row = (margin_frame[0], margin_frame[-1]) + first_column, last_column = (margin_frame[:, 0], margin_frame[:, -1]) + + # We add a bit of tolerance (about 1%) to account for possible rounding errors + assert np.allclose(first_row, [0, 0, 0], rtol=0.01) + assert np.allclose(last_row, [0, 0, 0], rtol=0.01) + assert np.allclose(first_column, [0, 0, 0], rtol=0.01) + assert np.allclose(last_column, [0, 0, 0], rtol=0.01) + + # We actually check on two pixels border, because some fonts + # always add a 1px padding all arround + first_two_rows, last_two_rows = (no_margin_frame[:2], no_margin_frame[-2:]) + first_two_columns, last_two_columns = (no_margin_frame[:, :2], no_margin_frame[:, -2:]) + + # We add a bit of tolerance (about 1%) to account for possible rounding errors + assert not ( + np.allclose(first_two_rows, [0, 0, 0], rtol=0.01) and + np.allclose(last_two_rows, [0, 0, 0], rtol=0.01) and + np.allclose(first_two_columns, [0, 0, 0], rtol=0.01) and + np.allclose(last_two_columns, [0, 0, 0], rtol=0.01) + ) + + + if __name__ == "__main__": pytest.main() From c9b4bc32f3236eb2db8323a9a06ea2fbaf8f5945 Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 14 Jan 2025 15:43:30 +0100 Subject: [PATCH 7/8] make test more robust --- tests/test_TextClip.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/test_TextClip.py b/tests/test_TextClip.py index 1b1f3d235..f19a4184b 100644 --- a/tests/test_TextClip.py +++ b/tests/test_TextClip.py @@ -6,6 +6,8 @@ import pytest +import string + from moviepy import * @@ -74,8 +76,11 @@ def test_no_text_nor_filename_arguments(method, util): def test_label_autosizing(util): - # We test with the letters usually triggering cutting such a ypj and ÀÉÔ - text = "ÀÉÔÇjpgy\nÀÉÔÇjpgy" + # We test with about all possible letters + text = "abcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęýABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęýABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęýABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text_clip_margin = TextClip(util.FONT, method="label", font_size=40, text=text, color="red", bg_color="black", stroke_width=3, stroke_color="white", margin=(1, 1)).with_duration(1) text_clip_no_margin = TextClip(util.FONT, method="label", font_size=40, text=text, color="red", bg_color="black", stroke_width=3, stroke_color="white").with_duration(1) @@ -94,18 +99,16 @@ def test_label_autosizing(util): assert np.allclose(first_column, [0, 0, 0], rtol=0.01) assert np.allclose(last_column, [0, 0, 0], rtol=0.01) - # We actually check on two pixels border, because some fonts - # always add a 1px padding all arround - first_two_rows, last_two_rows = (no_margin_frame[:2], no_margin_frame[-2:]) - first_two_columns, last_two_columns = (no_margin_frame[:, :2], no_margin_frame[:, -2:]) + # We actually check on three pixels border, because some fonts + # always add a 1px padding all arround and some rounding error can make it two + first_three_rows, last_three_rows = (no_margin_frame[:3], no_margin_frame[-3:]) + first_three_columns, last_three_columns = (no_margin_frame[:, :3], no_margin_frame[:, -3:]) # We add a bit of tolerance (about 1%) to account for possible rounding errors - assert not ( - np.allclose(first_two_rows, [0, 0, 0], rtol=0.01) and - np.allclose(last_two_rows, [0, 0, 0], rtol=0.01) and - np.allclose(first_two_columns, [0, 0, 0], rtol=0.01) and - np.allclose(last_two_columns, [0, 0, 0], rtol=0.01) - ) + assert not np.allclose(first_three_rows, [0, 0, 0], rtol=0.01) + assert not np.allclose(last_three_rows, [0, 0, 0], rtol=0.01) + assert not np.allclose(first_three_columns, [0, 0, 0], rtol=0.01) + assert not np.allclose(last_three_columns, [0, 0, 0], rtol=0.01) From 8433d3474c5f55eeb5f8805f98ca519af1b508cd Mon Sep 17 00:00:00 2001 From: osaajani <> Date: Tue, 14 Jan 2025 19:42:18 +0100 Subject: [PATCH 8/8] Add changelog and fix formatting --- CHANGELOG.md | 1 + moviepy/video/VideoClip.py | 16 +++++------ moviepy/video/io/ffmpeg_reader.py | 12 ++++---- tests/test_TextClip.py | 47 ++++++++++++++++++++++--------- 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3575a66d..1fd0bbf1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +- Fix textclip being cut or of impredictable height (see issues #2325, #2260 and #2268) ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 04587c16c..e64d8a37f 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1537,12 +1537,12 @@ class TextClip(ImageClip): bottom pixel of text - absolute top pixel of text. This is because in a font, some letter go above standard top line (e.g letters with accents), and bellow standard baseline (e.g letters such as - p, y, g). - + p, y, g). + This notion is knowned under the name ascent and descent meaning the highest and lowest pixel above and below baseline - If your first line dont have an "accent character" and your last line + If your first line dont have an "accent character" and your last line dont have a "descent character", you'll have some "fat" arround """ @@ -1801,7 +1801,7 @@ def __find_text_size( allow_break=False, ) -> tuple[int, int]: """Find *real* dimensions a text will occupy, return a tuple (width, height) - + .. note:: Text height calculation is quite complex due to how `Pillow` works. When calculating line height, `Pillow` actually uses the letter ``A`` @@ -1863,7 +1863,7 @@ def __find_text_size( ) text = "\n".join(lines) - + # Use multiline textbbox to get width left, top, right, bottom = draw.multiline_textbbox( (0, 0), @@ -1876,13 +1876,12 @@ def __find_text_size( ) # For height calculate manually as textbbox is not realiable - line_breaks = text.count('\n') + line_breaks = text.count("\n") lines_height = line_breaks * line_height paddings = real_font_size + stroke_width * 2 return (int(right - left), int(lines_height + paddings)) - def __find_optimum_font_size( self, text, @@ -1895,7 +1894,8 @@ def __find_optimum_font_size( allow_break=False, ): """Find the best font size to fit as optimally as possible - in a box of some width and optionally height""" + in a box of some width and optionally height + """ max_font_size = width min_font_size = 1 diff --git a/moviepy/video/io/ffmpeg_reader.py b/moviepy/video/io/ffmpeg_reader.py index 8f6835aa6..41de84142 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_TextClip.py b/tests/test_TextClip.py index f19a4184b..1d75c1b81 100644 --- a/tests/test_TextClip.py +++ b/tests/test_TextClip.py @@ -6,8 +6,6 @@ import pytest -import string - from moviepy import * @@ -77,17 +75,38 @@ def test_no_text_nor_filename_arguments(method, util): def test_label_autosizing(util): # We test with about all possible letters - text = "abcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęýABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" - text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęýABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" - text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęýABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" - + text = "abcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęý\ + ABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęý\ + ABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęý\ + ABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + + text_clip_margin = TextClip( + util.FONT, + method="label", + font_size=40, + text=text, + color="red", + bg_color="black", + stroke_width=3, + stroke_color="white", + margin=(1, 1), + ).with_duration(1) + text_clip_no_margin = TextClip( + util.FONT, + method="label", + font_size=40, + text=text, + color="red", + bg_color="black", + stroke_width=3, + stroke_color="white", + ).with_duration(1) - text_clip_margin = TextClip(util.FONT, method="label", font_size=40, text=text, color="red", bg_color="black", stroke_width=3, stroke_color="white", margin=(1, 1)).with_duration(1) - text_clip_no_margin = TextClip(util.FONT, method="label", font_size=40, text=text, color="red", bg_color="black", stroke_width=3, stroke_color="white").with_duration(1) - margin_frame = text_clip_margin.get_frame(1) no_margin_frame = text_clip_no_margin.get_frame(1) - + # The idea is, if autosizing work as expected, frame with 1px margin will # have black color all around, where frame without margin will have white somewhere first_row, last_row = (margin_frame[0], margin_frame[-1]) @@ -95,14 +114,17 @@ def test_label_autosizing(util): # We add a bit of tolerance (about 1%) to account for possible rounding errors assert np.allclose(first_row, [0, 0, 0], rtol=0.01) - assert np.allclose(last_row, [0, 0, 0], rtol=0.01) + assert np.allclose(last_row, [0, 0, 0], rtol=0.01) assert np.allclose(first_column, [0, 0, 0], rtol=0.01) assert np.allclose(last_column, [0, 0, 0], rtol=0.01) # We actually check on three pixels border, because some fonts # always add a 1px padding all arround and some rounding error can make it two first_three_rows, last_three_rows = (no_margin_frame[:3], no_margin_frame[-3:]) - first_three_columns, last_three_columns = (no_margin_frame[:, :3], no_margin_frame[:, -3:]) + first_three_columns, last_three_columns = ( + no_margin_frame[:, :3], + no_margin_frame[:, -3:], + ) # We add a bit of tolerance (about 1%) to account for possible rounding errors assert not np.allclose(first_three_rows, [0, 0, 0], rtol=0.01) @@ -111,6 +133,5 @@ def test_label_autosizing(util): assert not np.allclose(last_three_columns, [0, 0, 0], rtol=0.01) - if __name__ == "__main__": pytest.main()