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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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): From 940a9063ecdcbd694a2dc7a7a6e7e4b4287d15f5 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:45:01 -0600 Subject: [PATCH 7/9] Add color, outline, and background controls --- comfy_extras/nodes_text_overlay.py | 92 +++++++++++++++++------------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index 416d7ced7..fad4e050c 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -11,65 +11,76 @@ class TextOverlay(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="TextOverlay", - display_name="Text Overlay", - category="image/text", - description="Overlay text on an image or batch of images.", + display_name="Draw Text Overlay", + category="text", + description="Draw 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.Float.Input("font_size", default=5.0, min=0.5, max=50.0, step=0.5, tooltip="Font size as a percentage of the image height."), + IO.Color.Input("color", default="#ffffff", tooltip="Color of the text."), 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", 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), + IO.Boolean.Input("outline", default=True, tooltip="Draw a black outline around the text."), + IO.DynamicCombo.Input( + "background", + tooltip="Draw a banner behind the text to improve contrast.", + options=[ + IO.DynamicCombo.Option("disabled", []), + IO.DynamicCombo.Option("enabled", [ + IO.Color.Input("background_color", default="#00000080", tooltip="Color of the banner drawn behind the text. The color's opacity controls the banner transparency."), + ]), + ], + ), ], outputs=[IO.Image.Output()], ) @classmethod - 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: + def execute(cls, image, text, font_size, color, position, align, outline, background) -> IO.NodeOutput: if text.strip() == "": return IO.NodeOutput(image) text = text.replace("\\n", "\n").replace("\\t", "\t") - try: - text_rgb = ImageColor.getrgb(text_color)[:3] - except ValueError: - text_rgb = (255, 255, 255) + text_rgba = cls.parse_color_to_rgba(color) + outline_rgba = (0, 0, 0, 255) if outline else (0, 0, 0, 0) - # "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 + # The background banner opacity is set by the color's alpha channel, 50% by default + if background["background"] == "enabled": + background_rgba = cls.parse_color_to_rgba(background["background_color"]) else: - outline_rgb = ImageColor.getrgb(outline_color)[:3] - - background_rgb = contrast_rgb if outline_rgb is None else outline_rgb + background_rgba = (0, 0, 0, 0) # 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) + height = image.shape[1] + width = image.shape[2] + overlay_rgb, overlay_alpha = cls.render_overlay_text(width, height, text, position, align, font_size, + text_rgba, outline_rgba, background_rgba) 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) + @staticmethod + def parse_color_to_rgba(color_string): + parsed = ImageColor.getrgb(color_string) + + if len(parsed) == 3: + return (*parsed, 255) + + return parsed + @classmethod - 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): + def render_overlay_text(cls, width, height, text, position, align, font_size, text_rgba, outline_rgba, background_rgba): + line_spacing = 1.2 + 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) @@ -79,12 +90,12 @@ class TextOverlay(IO.ComfyNode): 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))) + size = max(1, int(round(font_size / 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_rgb is not None else 0 + stroke = max(1, int(round(size * outline_thickness_factor))) if outline_rgba[3] > 0 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) @@ -99,20 +110,21 @@ class TextOverlay(IO.ComfyNode): size = max(floor, int(size * 0.9)) - if background_opacity > 0: + if background_rgba[3] > 0: band = block_height + 2 * margin rect = [0, height - band, width, height] if position == "bottom" else [0, 0, width, band] - draw.rectangle(rect, fill=(*background_rgb, int(round(background_opacity * 255)))) + draw.rectangle(rect, fill=background_rgba) anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align] + # Offset y to better center text vertically within its defined band if position == "bottom": - anchor_v, y = "d", height - margin + y = height - margin - box[3] else: - anchor_v, y = "a", margin + y = margin - box[1] - 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) + draw.multiline_text((x, y), block, font=font, fill=text_rgba, anchor=anchor_h + "a", + align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_rgba) overlay = np.array(layer).astype(np.float32) / 255.0 overlay_rgb = torch.from_numpy(overlay[:, :, :3]) From 6ec4e57a2c589b79ae11b43b74d53bb3811bfaf8 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:15:37 -0600 Subject: [PATCH 8/9] Change background_color to bg_color --- comfy_extras/nodes_text_overlay.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index fad4e050c..9fb1bbf59 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -29,7 +29,7 @@ class TextOverlay(IO.ComfyNode): options=[ IO.DynamicCombo.Option("disabled", []), IO.DynamicCombo.Option("enabled", [ - IO.Color.Input("background_color", default="#00000080", tooltip="Color of the banner drawn behind the text. The color's opacity controls the banner transparency."), + IO.Color.Input("bg_color", default="#00000080", tooltip="Color of the banner drawn behind the text. The color's opacity controls the banner transparency."), ]), ], ), @@ -49,7 +49,7 @@ class TextOverlay(IO.ComfyNode): # The background banner opacity is set by the color's alpha channel, 50% by default if background["background"] == "enabled": - background_rgba = cls.parse_color_to_rgba(background["background_color"]) + background_rgba = cls.parse_color_to_rgba(background["bg_color"]) else: background_rgba = (0, 0, 0, 0) From 5ebf9883dd0237298174644ea5274c8ed7b65289 Mon Sep 17 00:00:00 2001 From: drozbay <17261091+drozbay@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:22:05 -0600 Subject: [PATCH 9/9] Remove background option until dynamic boolean inputs are available --- comfy_extras/nodes_text_overlay.py | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/comfy_extras/nodes_text_overlay.py b/comfy_extras/nodes_text_overlay.py index 9fb1bbf59..21bf7cd04 100644 --- a/comfy_extras/nodes_text_overlay.py +++ b/comfy_extras/nodes_text_overlay.py @@ -23,22 +23,12 @@ class TextOverlay(IO.ComfyNode): IO.Combo.Input("position", options=["top", "bottom"], default="top"), IO.Combo.Input("align", options=["left", "center", "right"], default="left"), IO.Boolean.Input("outline", default=True, tooltip="Draw a black outline around the text."), - IO.DynamicCombo.Input( - "background", - tooltip="Draw a banner behind the text to improve contrast.", - options=[ - IO.DynamicCombo.Option("disabled", []), - IO.DynamicCombo.Option("enabled", [ - IO.Color.Input("bg_color", default="#00000080", tooltip="Color of the banner drawn behind the text. The color's opacity controls the banner transparency."), - ]), - ], - ), ], outputs=[IO.Image.Output()], ) @classmethod - def execute(cls, image, text, font_size, color, position, align, outline, background) -> IO.NodeOutput: + def execute(cls, image, text, font_size, color, position, align, outline) -> IO.NodeOutput: if text.strip() == "": return IO.NodeOutput(image) @@ -47,17 +37,11 @@ class TextOverlay(IO.ComfyNode): text_rgba = cls.parse_color_to_rgba(color) outline_rgba = (0, 0, 0, 255) if outline else (0, 0, 0, 0) - # The background banner opacity is set by the color's alpha channel, 50% by default - if background["background"] == "enabled": - background_rgba = cls.parse_color_to_rgba(background["bg_color"]) - else: - background_rgba = (0, 0, 0, 0) - # Render the overlay once and composite it across all frames in the batch height = image.shape[1] width = image.shape[2] overlay_rgb, overlay_alpha = cls.render_overlay_text(width, height, text, position, align, font_size, - text_rgba, outline_rgba, background_rgba) + text_rgba, outline_rgba) overlay_rgb = overlay_rgb.to(device=image.device, dtype=image.dtype) overlay_alpha = overlay_alpha.to(device=image.device, dtype=image.dtype) @@ -74,7 +58,7 @@ class TextOverlay(IO.ComfyNode): return parsed @classmethod - def render_overlay_text(cls, width, height, text, position, align, font_size, text_rgba, outline_rgba, background_rgba): + def render_overlay_text(cls, width, height, text, position, align, font_size, text_rgba, outline_rgba): line_spacing = 1.2 margin_percent = 1.0 min_font_percent = 2.0 @@ -110,14 +94,9 @@ class TextOverlay(IO.ComfyNode): size = max(floor, int(size * 0.9)) - if background_rgba[3] > 0: - band = block_height + 2 * margin - rect = [0, height - band, width, height] if position == "bottom" else [0, 0, width, band] - draw.rectangle(rect, fill=background_rgba) - anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align] - # Offset y to better center text vertically within its defined band + # Offset y so the rendered text sits flush against the margin if position == "bottom": y = height - margin - box[3] else: