From 8978d2b2a375835500092f7c5e3f416b15349a29 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:35:08 -0600 Subject: [PATCH 1/6] Add native Text Overlay node for images and video Overlay text along the top of an image or image batch (video) without resizing it. Takes just an image and the text; the font scales with resolution and wraps to fit, long text fills and overflows rather than truncating, and a translucent banner with a contrasting outline keeps it legible on any background. CORE-137 --- comfy_extras/nodes_text_overlay.py | 119 +++++++++++++++++++++++++++++ nodes.py | 1 + 2 files changed, 120 insertions(+) create mode 100644 comfy_extras/nodes_text_overlay.py diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py new file mode 100644 index 000000000..ef05f19fd --- /dev/null +++ b/comfy_extras/nodes_text_overlay.py @@ -0,0 +1,119 @@ +import numpy as np +import torch +from PIL import Image as PILImage, ImageColor, ImageDraw, ImageFont +from typing_extensions import override + +from comfy_api.latest import ComfyExtension, IO + +LINE_SPACING = 1.2 +BANNER_OPACITY = 0.45 + + +class TextOverlay(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TextOverlay", + display_name="Text Overlay", + category="image/text", + description="Overlay text along the top of an image or batch of images.", + search_aliases=["text", "label", "caption", "subtitle", "watermark", "title", "addlabel", "overlay"], + inputs=[ + IO.Image.Input("image"), + IO.String.Input("text", multiline=True, default=""), + ], + outputs=[IO.Image.Output()], + ) + + @classmethod + def execute(cls, image, text, font_size_percent=5.0, text_color="white", outline=True, background=True, background_color="auto", margin_percent=1.0) -> IO.NodeOutput: + if text.strip() == "": + return IO.NodeOutput(image) + + try: + fill_color = ImageColor.getrgb(text_color)[:3] + except ValueError: + fill_color = (255, 255, 255) + if background_color.lower() == "auto": + luminance = 0.299 * fill_color[0] + 0.587 * fill_color[1] + 0.114 * fill_color[2] + contrast_color = (0, 0, 0) if luminance > 140 else (255, 255, 255) + else: + contrast_color = ImageColor.getrgb(background_color)[:3] + + frames = [cls.render_text_on_frame(frame, text, font_size_percent, margin_percent, fill_color, contrast_color, outline, background) + for frame in image] + return IO.NodeOutput(torch.stack(frames, dim=0)) + + @classmethod + def render_text_on_frame(cls, frame, text, font_size_percent, margin_percent, fill_color, contrast_color, outline, background): + pil_image = PILImage.fromarray((frame.clamp(0.0, 1.0).cpu().numpy() * 255.0).astype(np.uint8), mode="RGB") + width, height = pil_image.width, pil_image.height + + margin = int(round(margin_percent / 100.0 * min(width, height))) + max_width = max(1, width - 2 * margin) + max_height = max(1, height - 2 * margin) + + # Font scales with resolution, then shrinks to fit the height. + size = max(1, int(round(font_size_percent / 100.0 * height))) + floor = min(size, max(10, int(round(0.02 * height)))) + while True: + font = ImageFont.load_default(size=size) + lines = cls.wrap_text(text, font, max_width) + line_height = size * LINE_SPACING + if line_height * len(lines) <= max_height or size <= floor: + break + size = max(floor, int(size * 0.9)) + + if background: + banner_bottom = 2 * margin + line_height * len(lines) + overlay = PILImage.new("RGBA", pil_image.size, (0, 0, 0, 0)) + ImageDraw.Draw(overlay).rectangle([0, 0, width, banner_bottom], fill=(*contrast_color, int(round(BANNER_OPACITY * 255)))) + pil_image = PILImage.alpha_composite(pil_image.convert("RGBA"), overlay).convert("RGB") + + draw = ImageDraw.Draw(pil_image) + stroke = max(1, int(round(size / 24))) if outline else 0 + for index, line in enumerate(lines): + draw.text((margin, margin + index * line_height), line, font=font, + fill=fill_color, stroke_width=stroke, stroke_fill=contrast_color) + + return torch.from_numpy(np.array(pil_image).astype(np.float32) / 255.0) + + @staticmethod + def wrap_text(text, font, max_width): + lines = [] + for raw_line in text.split("\n"): + words = raw_line.split() + if not words: + lines.append("") + continue + current = "" + # Break the line into words and split words that are too long + for word in words: + while font.getlength(word) > max_width and len(word) > 1: + cut = 1 + while cut < len(word) and font.getlength(word[:cut + 1]) <= max_width: + cut += 1 + if current: + lines.append(current) + current = "" + lines.append(word[:cut]) + word = word[cut:] + candidate = word if not current else current + " " + word + if not current or font.getlength(candidate) <= max_width: + current = candidate + else: + lines.append(current) + current = word + if current: + lines.append(current) + return lines + + +class TextOverlayExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [TextOverlay] + + +async def comfy_entrypoint() -> TextOverlayExtension: + return TextOverlayExtension() diff --git a/nodes.py b/nodes.py index 028e58c77..b46484d74 100644 --- a/nodes.py +++ b/nodes.py @@ -2452,6 +2452,7 @@ async def init_builtin_extra_nodes(): "nodes_glsl.py", "nodes_lora_debug.py", "nodes_textgen.py", + "nodes_text_overlay.py", "nodes_color.py", "nodes_toolkit.py", "nodes_replacements.py", From 0480cb5bdf28da1768bfd7e5fce39a336cd0c3f7 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:00:24 -0600 Subject: [PATCH 2/6] Disable the text overlay banner by default --- comfy_extras/nodes_text_overlay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index ef05f19fd..6b4b293bb 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -26,7 +26,7 @@ class TextOverlay(IO.ComfyNode): ) @classmethod - def execute(cls, image, text, font_size_percent=5.0, text_color="white", outline=True, background=True, background_color="auto", margin_percent=1.0) -> IO.NodeOutput: + def execute(cls, image, text, font_size_percent=5.0, text_color="white", outline=True, background=False, background_color="auto", margin_percent=1.0) -> IO.NodeOutput: if text.strip() == "": return IO.NodeOutput(image) From 4cfbb19fd9417bf7295f92efb2f07888d779ceb4 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:11:02 -0600 Subject: [PATCH 3/6] Handle newline character --- comfy_extras/nodes_text_overlay.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index 6b4b293bb..dfdf8e9f7 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -30,6 +30,8 @@ class TextOverlay(IO.ComfyNode): if text.strip() == "": return IO.NodeOutput(image) + text = text.replace("\\n", "\n").replace("\\t", "\t") + try: fill_color = ImageColor.getrgb(text_color)[:3] except ValueError: From e99602e5abfe503bad59784787265ea4f6e01482 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:35:42 -0600 Subject: [PATCH 4/6] Condense input parameters --- comfy_extras/nodes_text_overlay.py | 49 ++++++++++++++++++------------ 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index dfdf8e9f7..6e5fcbf8b 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -5,9 +5,6 @@ from typing_extensions import override from comfy_api.latest import ComfyExtension, IO -LINE_SPACING = 1.2 -BANNER_OPACITY = 0.45 - class TextOverlay(IO.ComfyNode): @classmethod @@ -21,33 +18,45 @@ class TextOverlay(IO.ComfyNode): inputs=[ IO.Image.Input("image"), IO.String.Input("text", multiline=True, default=""), + IO.Float.Input("font_size_percent", default=5.0, min=0.5, max=50.0, step=0.5, tooltip="Font size as a percentage of the image height.", advanced=True), + #IO.Combo.Input("text_color", options=["white", "black", "red", "green", "blue", "yellow", "cyan", "magenta", "gray"], default="white"), + #IO.Combo.Input("outline_color", options=["auto", "none", "black", "white", "red", "green", "blue", "yellow"], default="auto"), ], outputs=[IO.Image.Output()], ) @classmethod - def execute(cls, image, text, font_size_percent=5.0, text_color="white", outline=True, background=False, background_color="auto", margin_percent=1.0) -> IO.NodeOutput: + def execute(cls, image, text, font_size_percent=5.0, text_color="white", outline_color="auto", margin_percent=1.0, + line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04) -> IO.NodeOutput: if text.strip() == "": return IO.NodeOutput(image) text = text.replace("\\n", "\n").replace("\\t", "\t") try: - fill_color = ImageColor.getrgb(text_color)[:3] + text_color = ImageColor.getrgb(text_color)[:3] except ValueError: - fill_color = (255, 255, 255) - if background_color.lower() == "auto": - luminance = 0.299 * fill_color[0] + 0.587 * fill_color[1] + 0.114 * fill_color[2] - contrast_color = (0, 0, 0) if luminance > 140 else (255, 255, 255) - else: - contrast_color = ImageColor.getrgb(background_color)[:3] + text_color = (255, 255, 255) - frames = [cls.render_text_on_frame(frame, text, font_size_percent, margin_percent, fill_color, contrast_color, outline, background) + luminance = 0.299 * text_color[0] + 0.587 * text_color[1] + 0.114 * text_color[2] + contrast_color = (0, 0, 0) if luminance > 40 else (255, 255, 255) + choice = outline_color.lower() + if choice == "none": + outline_color = None + elif choice == "auto": + outline_color = contrast_color + else: + outline_color = ImageColor.getrgb(outline_color)[:3] + background_color = contrast_color if outline_color is None else outline_color + + frames = [cls.render_text_on_frame(frame, text, font_size_percent, margin_percent, text_color, outline_color, background_color, + line_spacing, background_opacity, min_font_percent, outline_thickness_factor) for frame in image] return IO.NodeOutput(torch.stack(frames, dim=0)) @classmethod - def render_text_on_frame(cls, frame, text, font_size_percent, margin_percent, fill_color, contrast_color, outline, background): + def render_text_on_frame(cls, frame, text, font_size_percent, margin_percent, text_color, outline_color, background_color, + line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04, min_font_px=10): pil_image = PILImage.fromarray((frame.clamp(0.0, 1.0).cpu().numpy() * 255.0).astype(np.uint8), mode="RGB") width, height = pil_image.width, pil_image.height @@ -57,26 +66,26 @@ class TextOverlay(IO.ComfyNode): # Font scales with resolution, then shrinks to fit the height. size = max(1, int(round(font_size_percent / 100.0 * height))) - floor = min(size, max(10, int(round(0.02 * height)))) + floor = min(size, max(min_font_px, int(round(min_font_percent / 100.0 * height)))) while True: font = ImageFont.load_default(size=size) lines = cls.wrap_text(text, font, max_width) - line_height = size * LINE_SPACING + line_height = size * line_spacing if line_height * len(lines) <= max_height or size <= floor: break size = max(floor, int(size * 0.9)) - if background: - banner_bottom = 2 * margin + line_height * len(lines) + if background_opacity > 0: + background_bottom = 2 * margin + line_height * len(lines) overlay = PILImage.new("RGBA", pil_image.size, (0, 0, 0, 0)) - ImageDraw.Draw(overlay).rectangle([0, 0, width, banner_bottom], fill=(*contrast_color, int(round(BANNER_OPACITY * 255)))) + ImageDraw.Draw(overlay).rectangle([0, 0, width, background_bottom], fill=(*background_color, int(round(background_opacity * 255)))) pil_image = PILImage.alpha_composite(pil_image.convert("RGBA"), overlay).convert("RGB") draw = ImageDraw.Draw(pil_image) - stroke = max(1, int(round(size / 24))) if outline else 0 + stroke = max(1, int(round(size * outline_thickness_factor))) if outline_color is not None else 0 for index, line in enumerate(lines): draw.text((margin, margin + index * line_height), line, font=font, - fill=fill_color, stroke_width=stroke, stroke_fill=contrast_color) + fill=text_color, stroke_width=stroke, stroke_fill=outline_color) return torch.from_numpy(np.array(pil_image).astype(np.float32) / 255.0) From f6daf4301dee9626b7d4bc9914a25e2488cbe96b Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:20:24 -0600 Subject: [PATCH 5/6] Add position and alignment options --- comfy_extras/nodes_text_overlay.py | 54 ++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index 6e5fcbf8b..4ab30e8c5 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -13,20 +13,23 @@ class TextOverlay(IO.ComfyNode): node_id="TextOverlay", display_name="Text Overlay", category="image/text", - description="Overlay text along the top of an image or batch of images.", + description="Overlay text on an image or batch of images.", search_aliases=["text", "label", "caption", "subtitle", "watermark", "title", "addlabel", "overlay"], inputs=[ IO.Image.Input("image"), IO.String.Input("text", multiline=True, default=""), + IO.Combo.Input("position", options=["top", "bottom"], default="top"), + IO.Combo.Input("align", options=["left", "center", "right"], default="left"), IO.Float.Input("font_size_percent", default=5.0, min=0.5, max=50.0, step=0.5, tooltip="Font size as a percentage of the image height.", advanced=True), - #IO.Combo.Input("text_color", options=["white", "black", "red", "green", "blue", "yellow", "cyan", "magenta", "gray"], default="white"), - #IO.Combo.Input("outline_color", options=["auto", "none", "black", "white", "red", "green", "blue", "yellow"], default="auto"), + #IO.Combo.Input("text_color", options=["white", "black", "red", "green", "blue", "yellow", "cyan", "magenta", "gray"], default="white", tooltip="Color of the text.", advanced=True), + #IO.Combo.Input("outline_color", options=["auto", "none", "black", "white", "red", "green", "blue", "yellow"], default="auto", tooltip="Color of the text outline.", advanced=True), + #IO.Float.Input("background_opacity", default=0.0, min=0.0, max=1.0, step=0.05, tooltip="Opacity of the background behind the text (0 = transparent, 1 = solid).", advanced=True), ], outputs=[IO.Image.Output()], ) @classmethod - def execute(cls, image, text, font_size_percent=5.0, text_color="white", outline_color="auto", margin_percent=1.0, + def execute(cls, image, text, position="top", align="left", font_size_percent=5.0, text_color="white", outline_color="auto", margin_percent=1.0, line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04) -> IO.NodeOutput: if text.strip() == "": return IO.NodeOutput(image) @@ -49,16 +52,17 @@ class TextOverlay(IO.ComfyNode): outline_color = ImageColor.getrgb(outline_color)[:3] background_color = contrast_color if outline_color is None else outline_color - frames = [cls.render_text_on_frame(frame, text, font_size_percent, margin_percent, text_color, outline_color, background_color, + frames = [cls.render_text_on_frame(frame, text, position, align, font_size_percent, margin_percent, text_color, outline_color, background_color, line_spacing, background_opacity, min_font_percent, outline_thickness_factor) for frame in image] return IO.NodeOutput(torch.stack(frames, dim=0)) @classmethod - def render_text_on_frame(cls, frame, text, font_size_percent, margin_percent, text_color, outline_color, background_color, - line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04, min_font_px=10): + def render_text_on_frame(cls, frame, text, position="top", align="left", font_size_percent=5.0, margin_percent=1.0, text_color="white", outline_color="auto", + background_color=None, line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04, min_font_px=10): pil_image = PILImage.fromarray((frame.clamp(0.0, 1.0).cpu().numpy() * 255.0).astype(np.uint8), mode="RGB") width, height = pil_image.width, pil_image.height + draw = ImageDraw.Draw(pil_image) margin = int(round(margin_percent / 100.0 * min(width, height))) max_width = max(1, width - 2 * margin) @@ -67,25 +71,41 @@ class TextOverlay(IO.ComfyNode): # Font scales with resolution, then shrinks to fit the height. size = max(1, int(round(font_size_percent / 100.0 * height))) floor = min(size, max(min_font_px, int(round(min_font_percent / 100.0 * height)))) + while True: font = ImageFont.load_default(size=size) - lines = cls.wrap_text(text, font, max_width) - line_height = size * line_spacing - if line_height * len(lines) <= max_height or size <= floor: + stroke = max(1, int(round(size * outline_thickness_factor))) if outline_color is not None else 0 + block = "\n".join(cls.wrap_text(text, font, max_width)) + # convert line spacing to pixel spacing + single = draw.textbbox((0, 0), "Ay", font=font, stroke_width=stroke) + double = draw.multiline_textbbox((0, 0), "Ay\nAy", font=font, spacing=0, stroke_width=stroke) + natural_advance = (double[3] - double[1]) - (single[3] - single[1]) + pixel_spacing = int(round(size * line_spacing - natural_advance)) + box = draw.multiline_textbbox((0, 0), block, font=font, spacing=pixel_spacing, stroke_width=stroke) + block_height = box[3] - box[1] + + if block_height <= max_height or size <= floor: break + size = max(floor, int(size * 0.9)) if background_opacity > 0: - background_bottom = 2 * margin + line_height * len(lines) + band = block_height + 2 * margin + rect = [0, height - band, width, height] if position == "bottom" else [0, 0, width, band] overlay = PILImage.new("RGBA", pil_image.size, (0, 0, 0, 0)) - ImageDraw.Draw(overlay).rectangle([0, 0, width, background_bottom], fill=(*background_color, int(round(background_opacity * 255)))) + ImageDraw.Draw(overlay).rectangle(rect, fill=(*background_color, int(round(background_opacity * 255)))) pil_image = PILImage.alpha_composite(pil_image.convert("RGBA"), overlay).convert("RGB") + draw = ImageDraw.Draw(pil_image) - draw = ImageDraw.Draw(pil_image) - stroke = max(1, int(round(size * outline_thickness_factor))) if outline_color is not None else 0 - for index, line in enumerate(lines): - draw.text((margin, margin + index * line_height), line, font=font, - fill=text_color, stroke_width=stroke, stroke_fill=outline_color) + anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align] + + if position == "bottom": + anchor_v, y = "d", height - margin + else: + anchor_v, y = "a", margin + + draw.multiline_text((x, y), block, font=font, fill=text_color, anchor=anchor_h + anchor_v, + align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_color) return torch.from_numpy(np.array(pil_image).astype(np.float32) / 255.0) From d8711f0460db2f635f2d5b1b7908cf1602baa1ac Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:43:36 -0600 Subject: [PATCH 6/6] Improve rendering on batches of images, and some general code flow improvements --- comfy_extras/nodes_text_overlay.py | 72 +++++++++++++++++------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index 4ab30e8c5..416d7ced7 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -29,40 +29,50 @@ class TextOverlay(IO.ComfyNode): ) @classmethod - def execute(cls, image, text, position="top", align="left", font_size_percent=5.0, text_color="white", outline_color="auto", margin_percent=1.0, - line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04) -> IO.NodeOutput: + def execute(cls, image, text, position="top", align="left", font_size_percent=5.0, text_color="white", outline_color="auto", + line_spacing=1.2, background_opacity=0.0) -> IO.NodeOutput: if text.strip() == "": return IO.NodeOutput(image) text = text.replace("\\n", "\n").replace("\\t", "\t") try: - text_color = ImageColor.getrgb(text_color)[:3] + text_rgb = ImageColor.getrgb(text_color)[:3] except ValueError: - text_color = (255, 255, 255) + text_rgb = (255, 255, 255) - luminance = 0.299 * text_color[0] + 0.587 * text_color[1] + 0.114 * text_color[2] - contrast_color = (0, 0, 0) if luminance > 40 else (255, 255, 255) - choice = outline_color.lower() - if choice == "none": - outline_color = None - elif choice == "auto": - outline_color = contrast_color + # "auto" outline color: set the outline color based on text color to maintain contrast + luminance = 0.299 * text_rgb[0] + 0.587 * text_rgb[1] + 0.114 * text_rgb[2] + contrast_rgb = (0, 0, 0) if luminance > 40 else (255, 255, 255) + outline_choice = outline_color.lower() + + if outline_choice == "none": + outline_rgb = None + elif outline_choice == "auto": + outline_rgb = contrast_rgb else: - outline_color = ImageColor.getrgb(outline_color)[:3] - background_color = contrast_color if outline_color is None else outline_color + outline_rgb = ImageColor.getrgb(outline_color)[:3] - frames = [cls.render_text_on_frame(frame, text, position, align, font_size_percent, margin_percent, text_color, outline_color, background_color, - line_spacing, background_opacity, min_font_percent, outline_thickness_factor) - for frame in image] - return IO.NodeOutput(torch.stack(frames, dim=0)) + background_rgb = contrast_rgb if outline_rgb is None else outline_rgb + + # Render the overlay once and composite it across all frames in the batch + height = int(image.shape[1]) + width = int(image.shape[2]) + overlay_rgb, overlay_alpha = cls.render_overlay_text(width, height, text, position, align, font_size_percent, + text_rgb, outline_rgb, background_rgb, line_spacing, background_opacity) + overlay_rgb = overlay_rgb.to(device=image.device, dtype=image.dtype) + overlay_alpha = overlay_alpha.to(device=image.device, dtype=image.dtype) + + result = image * (1.0 - overlay_alpha) + overlay_rgb * overlay_alpha + return IO.NodeOutput(result) @classmethod - def render_text_on_frame(cls, frame, text, position="top", align="left", font_size_percent=5.0, margin_percent=1.0, text_color="white", outline_color="auto", - background_color=None, line_spacing=1.2, background_opacity=0.0, min_font_percent=2.0, outline_thickness_factor=0.04, min_font_px=10): - pil_image = PILImage.fromarray((frame.clamp(0.0, 1.0).cpu().numpy() * 255.0).astype(np.uint8), mode="RGB") - width, height = pil_image.width, pil_image.height - draw = ImageDraw.Draw(pil_image) + def render_overlay_text(cls, width, height, text, position="top", align="left", font_size_percent=5.0, + text_rgb=(255, 255, 255), outline_rgb=None, background_rgb=None, line_spacing=1.2, background_opacity=0.0, + margin_percent=1.0, min_font_percent=2.0, min_font_pixels=10, outline_thickness_factor=0.04): + # Draw onto a transparent layer so the result can be alpha-composited over any frame. + layer = PILImage.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) margin = int(round(margin_percent / 100.0 * min(width, height))) max_width = max(1, width - 2 * margin) @@ -70,11 +80,11 @@ class TextOverlay(IO.ComfyNode): # Font scales with resolution, then shrinks to fit the height. size = max(1, int(round(font_size_percent / 100.0 * height))) - floor = min(size, max(min_font_px, int(round(min_font_percent / 100.0 * height)))) + floor = min(size, max(min_font_pixels, int(round(min_font_percent / 100.0 * height)))) while True: font = ImageFont.load_default(size=size) - stroke = max(1, int(round(size * outline_thickness_factor))) if outline_color is not None else 0 + stroke = max(1, int(round(size * outline_thickness_factor))) if outline_rgb is not None else 0 block = "\n".join(cls.wrap_text(text, font, max_width)) # convert line spacing to pixel spacing single = draw.textbbox((0, 0), "Ay", font=font, stroke_width=stroke) @@ -92,10 +102,7 @@ class TextOverlay(IO.ComfyNode): if background_opacity > 0: band = block_height + 2 * margin rect = [0, height - band, width, height] if position == "bottom" else [0, 0, width, band] - overlay = PILImage.new("RGBA", pil_image.size, (0, 0, 0, 0)) - ImageDraw.Draw(overlay).rectangle(rect, fill=(*background_color, int(round(background_opacity * 255)))) - pil_image = PILImage.alpha_composite(pil_image.convert("RGBA"), overlay).convert("RGB") - draw = ImageDraw.Draw(pil_image) + draw.rectangle(rect, fill=(*background_rgb, int(round(background_opacity * 255)))) anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align] @@ -104,10 +111,13 @@ class TextOverlay(IO.ComfyNode): else: anchor_v, y = "a", margin - draw.multiline_text((x, y), block, font=font, fill=text_color, anchor=anchor_h + anchor_v, - align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_color) + draw.multiline_text((x, y), block, font=font, fill=text_rgb, anchor=anchor_h + anchor_v, + align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_rgb) - return torch.from_numpy(np.array(pil_image).astype(np.float32) / 255.0) + overlay = np.array(layer).astype(np.float32) / 255.0 + overlay_rgb = torch.from_numpy(overlay[:, :, :3]) + overlay_alpha = torch.from_numpy(overlay[:, :, 3:4]) + return overlay_rgb, overlay_alpha @staticmethod def wrap_text(text, font, max_width):