Files
scofano-ComfyUI-Advanced-Te…/nodes.py
T
2025-10-24 13:49:05 -03:00

398 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
class TextOverlay:
"""
Text overlay node with:
- Fill/stroke alpha
- Shadow and background box
- Pixel-perfect stroke alignment for MULTILINE text
- Even stroke width for crisper edges
- Default vertical_alignment = 'middle'
"""
_horizontal_alignments = ["left", "center", "right"]
_vertical_alignments = ["top", "middle", "bottom"]
def __init__(self, device="cpu"):
self.device = device
self._loaded_font = None
# cache (lines, widths, heights, tops, min_top, min_left, block_w, block_h)
self._cached = None
self._x = None
self._y = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# keep image first so the node wiring stays obvious
"image": ("IMAGE",),
# UI order (top → bottom)
"text": ("STRING", {"multiline": True, "default": "the quick brown fox\njumps over the lazy dog"}),
"all_caps": ("BOOLEAN", {"default": False}),
# font, font-size, font color, font alpha
"font": ("STRING", {"default": "ariblk.ttf"}),
"font_size": ("INT", {"default": 32, "min": 1, "max": 9999, "step": 1}),
"fill_color_hex": ("STRING", {"default": "#FFFFFF"}),
"fill_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}),
# padding, hor_align, x_shift, vert align, y_shift, line_spacing
"padding": ("INT", {"default": 16, "min": 0, "max": 256, "step": 1}),
"vertical_alignment": (cls._vertical_alignments, {"default": "middle"}),
"y_shift": ("INT", {"default": 0, "min": -256, "max": 256, "step": 1}),
"horizontal_alignment": (cls._horizontal_alignments, {"default": "center"}),
"x_shift": ("INT", {"default": 0, "min": -256, "max": 256, "step": 1}),
"line_spacing": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 50.0, "step": 0.5}),
# all strokes
"stroke_enable": ("BOOLEAN", {"default": True}),
"stroke_color_hex": ("STRING", {"default": "#000000"}),
"stroke_thickness": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}),
"stroke_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}),
# all boxes
"bg_enable": ("BOOLEAN", {"default": False}),
"bg_padding": ("INT", {"default": 8, "min": 0, "max": 256, "step": 1}),
"bg_radius": ("INT", {"default": 8, "min": 0, "max": 512, "step": 1}),
"bg_color_hex": ("STRING", {"default": "#000000"}),
"bg_alpha": ("FLOAT", {"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}),
# all shadows
"shadow_enable": ("BOOLEAN", {"default": False}),
"shadow_distance": ("INT", {"default": 4, "min": -50, "max": 50, "step": 1}),
"shadow_color_hex": ("STRING", {"default": "#000000"}),
"shadow_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "batch_process"
CATEGORY = "image/text"
# ---------------- helpers ----------------
def hex_to_rgb(self, hex_color: str):
hex_color = hex_color.strip().lstrip("#")
if len(hex_color) == 3:
hex_color = "".join(ch * 2 for ch in hex_color)
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
def _normalize_text(self, text: str) -> str:
# Convert literal "\n" to actual newlines
return text.replace("\\n", "\n").replace("\\N", "\n")
def _load_font(self, font, font_size):
fonts_dir = os.path.join(os.path.dirname(__file__), "fonts")
font_path = os.path.join(fonts_dir, font)
if not os.path.exists(font_path):
font_path = font # fallback to system path or provided absolute path
try:
return ImageFont.truetype(font_path, font_size)
except Exception as e:
print(f"Error loading font: {e} — using default font")
return ImageFont.load_default()
def _wrap_lines(self, draw, text, font, max_width, padding):
"""
Greedy wrapping that respects explicit newlines and preserves spaces
(including multiple consecutive spaces).
"""
import re
paragraphs = self._normalize_text(text).split("\n")
out_lines = []
for para in paragraphs:
# Keep empty lines exactly
if para == "":
out_lines.append("")
continue
# Tokens are either non-space runs or whitespace runs
tokens = re.findall(r"\S+|\s+", para)
line = ""
for tok in tokens:
candidate = line + tok
if draw.textlength(candidate, font=font) <= (max_width - 2 * padding):
line = candidate
else:
if line == "":
# Single token longer than the max width: force-break
out_lines.append(candidate)
line = ""
else:
out_lines.append(line)
# Start new line with current token (trim leading spaces only)
line = tok.lstrip()
# Push whatever remains (don’t strip—preserve trailing spaces)
out_lines.append(line)
return out_lines
# ---------------- core drawing ----------------
def _compute_layout(self, img_w, img_h, draw, text, font, stroke_width, padding,
h_align, v_align, x_shift, y_shift, line_spacing, use_cache):
"""
Manual multiline layout so stroke/fill line positions are identical.
IMPORTANT: We compute the visual extents (min_left / max_right) from each line's
textbbox (which already includes stroke when stroke_width > 0). This gives a
correct block width for the background box and eliminates the right-side gap
when stroke is enabled.
Returns:
(lines, widths, heights, tops, min_top, min_left, block_w, block_h, x0, visual_top_y)
"""
if self._cached is None or not use_cache:
lines = self._wrap_lines(draw, text, font, img_w, padding)
widths, heights, tops = [], [], []
lefts, rights = [], []
for ln in lines:
# bbox includes stroke when stroke_width > 0
l, t, r, b = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
widths.append(r - l)
heights.append(b - t)
tops.append(t)
lefts.append(l)
rights.append(r)
min_left = min(lefts) if lefts else 0
max_right = max(rights) if rights else 0
block_w = max_right - min_left if rights else 0
block_h = sum(heights) + (len(heights) - 1) * line_spacing if heights else 0
min_top = min(tops) if tops else 0 # may be negative for ascenders
# cache results to reuse on subsequent frames
self._cached = (lines, widths, heights, tops, min_top, min_left, block_w, block_h)
lines, widths, heights, tops, min_top, min_left, block_w, block_h = self._cached
# Horizontal anchor for the *visual* block (uses min_left/max_right)
if h_align == "left":
x0 = padding
elif h_align == "center":
x0 = (img_w - block_w) / 2
else: # right
x0 = img_w - block_w - padding
# Vertical anchor for the *visual* block
if v_align == "top":
visual_top_y = padding
elif v_align == "middle":
visual_top_y = (img_h - block_h) / 2
else: # bottom
visual_top_y = img_h - block_h - padding
# Snap + shifts
x0 = int(round(x0 + x_shift))
visual_top_y = int(round(visual_top_y + y_shift))
return lines, widths, heights, tops, min_top, min_left, block_w, block_h, x0, visual_top_y
def draw_text(
self,
image,
text,
all_caps,
font_size,
font,
fill_color_hex,
fill_alpha,
stroke_enable,
stroke_color_hex,
stroke_alpha,
stroke_thickness,
padding,
horizontal_alignment,
vertical_alignment,
x_shift,
y_shift,
line_spacing,
bg_enable,
bg_color_hex,
bg_alpha,
bg_padding,
bg_radius,
shadow_enable,
shadow_color_hex,
shadow_alpha,
shadow_distance,
use_cache=False,
):
# Prepare
if image.mode != "RGBA":
image = image.convert("RGBA")
self._loaded_font = self._load_font(font, font_size)
draw = ImageDraw.Draw(image, "RGBA")
# Apply ALL CAPS if requested (after literal "\n" handling via _wrap_lines)
if all_caps:
# Uppercase before wrapping so measurement/layout reflect final glyphs
text = text.upper()
# Compute even stroke width (recommended for crisp outlines)
sw = int(round(font_size * stroke_thickness * 0.5)) if stroke_enable else 0
if sw % 2 == 1 and sw > 0:
sw += 1
# Layout (manual lines)
(lines, widths, heights, tops, min_top, min_left, block_w, block_h,
x0, visual_top_y) = self._compute_layout(
image.width, image.height, draw, text, self._loaded_font, sw,
padding, horizontal_alignment, vertical_alignment, x_shift, y_shift,
line_spacing, use_cache
)
# Convert the visual top into the baseline y for the first line
first_line_baseline_y = visual_top_y - min_top
# This is the x where we actually draw glyphs so that the visual left aligns to x0
x_draw = x0 - min_left
# Background box (perfectly aligned with visual extents)
if bg_enable and block_w > 0 and block_h > 0:
br, bgc, bb = self.hex_to_rgb(bg_color_hex)
ba = int(max(0.0, min(1.0, bg_alpha)) * 255)
rect = [x0 - bg_padding, visual_top_y - bg_padding,
x0 + block_w + bg_padding, visual_top_y + block_h + bg_padding]
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
od = ImageDraw.Draw(overlay, "RGBA")
try:
od.rounded_rectangle(rect, radius=max(0, int(bg_radius)), fill=(br, bgc, bb, ba))
except Exception:
od.rectangle(rect, fill=(br, bgc, bb, ba))
image = Image.alpha_composite(image, overlay)
# Shadow (per-line, before stroke/fill)
if shadow_enable and block_w > 0 and block_h > 0:
sh_r, sh_g, sh_b = self.hex_to_rgb(shadow_color_hex)
sh_a = int(max(0.0, min(1.0, shadow_alpha)) * 255)
sdx = sdy = int(shadow_distance)
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
od = ImageDraw.Draw(overlay, "RGBA")
yy = first_line_baseline_y
for ln, h in zip(lines, heights):
od.text((x_draw + sdx, yy + sdy), ln, font=self._loaded_font, fill=(sh_r, sh_g, sh_b, sh_a))
yy += int(round(h + line_spacing))
image = Image.alpha_composite(image, overlay)
# Stroke + Fill (per-line; identical geometry/positions)
fr, fg, fb = self.hex_to_rgb(fill_color_hex)
fa = int(max(0.0, min(1.0, fill_alpha)) * 255)
sr, sg, sb = self.hex_to_rgb(stroke_color_hex)
sa = int(max(0.0, min(1.0, stroke_alpha)) * 255)
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
od = ImageDraw.Draw(overlay, "RGBA")
yy = first_line_baseline_y
for ln, h in zip(lines, heights):
if sw > 0 and sa > 0:
od.text(
(x_draw, yy),
ln,
font=self._loaded_font,
fill=(sr, sg, sb, sa),
stroke_width=sw,
stroke_fill=(sr, sg, sb, sa),
)
if fa > 0:
od.text(
(x_draw, yy),
ln,
font=self._loaded_font,
fill=(fr, fg, fb, fa),
)
yy += int(round(h + line_spacing))
image = Image.alpha_composite(image, overlay)
return image.convert("RGB")
# ---------------- Comfy entrypoint ----------------
def batch_process(
self,
image,
text,
all_caps, # NEW
font_size,
font,
fill_color_hex,
fill_alpha,
stroke_enable,
stroke_color_hex,
stroke_alpha,
stroke_thickness,
padding,
horizontal_alignment,
vertical_alignment,
x_shift,
y_shift,
line_spacing,
bg_enable,
bg_color_hex,
bg_alpha,
bg_padding,
bg_radius,
shadow_enable,
shadow_color_hex,
shadow_alpha,
shadow_distance,
):
"""Handles both single and batch image processing (with layout caching across frames)."""
# Single image
if len(image.shape) == 3:
np_img = image.cpu().numpy()
pil_img = Image.fromarray((np_img * 255).astype(np.uint8))
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, font,
fill_color_hex, fill_alpha,
stroke_enable,
stroke_color_hex, stroke_alpha, stroke_thickness,
padding, horizontal_alignment, vertical_alignment,
x_shift, y_shift, line_spacing,
bg_enable, bg_color_hex, bg_alpha, bg_padding, bg_radius,
shadow_enable, shadow_color_hex, shadow_alpha, shadow_distance,
use_cache=False,
)
out = np.array(out_img).astype(np.float32) / 255.0
return (torch.tensor(out),)
# Batch of images
else:
np_imgs = image.cpu().numpy()
outs = []
use_cache = False
for arr in np_imgs:
pil_img = Image.fromarray((arr * 255).astype(np.uint8))
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, font,
fill_color_hex, fill_alpha,
stroke_enable,
stroke_color_hex, stroke_alpha, stroke_thickness,
padding, horizontal_alignment, vertical_alignment,
x_shift, y_shift, line_spacing,
bg_enable, bg_color_hex, bg_alpha, bg_padding, bg_radius,
shadow_enable, shadow_color_hex, shadow_alpha, shadow_distance,
use_cache=use_cache, # reuse wrapped lines/metrics for next frames
)
outs.append(np.array(out_img).astype(np.float32) / 255.0)
use_cache = True
return (torch.tensor(np.stack(outs)),)
NODE_CLASS_MAPPINGS = {"Advanced Text Overlay": TextOverlay}