23 Commits
Author SHA1 Message Date
scofano 89992997c5 Rename animated2.gif to animated.gif 2025-10-29 11:02:01 -03:00
scofano 67d3c6d9d4 Rename animated.gif to animated2.gif 2025-10-29 11:01:44 -03:00
scofano 29e0bbdad6 Bump version to 1.3.0
Update the project version in pyproject.toml from 1.2.0 to 1.3.0 to reflect new changes or features.
2025-10-29 10:59:56 -03:00
scofano dbf6449949 Add animation feature to text overlay node
Introduces a new animation capability for the text overlay node, supporting various animation types (fade, move) and easing modes. The README is updated with detailed documentation on animation parameters, supported types, and technical integration.
2025-10-29 10:58:18 -03:00
scofano 405e4b4d19 Add animation support to TextOverlay node
Introduces a new animations.py module with easing, opacity, and offset helpers. Updates nodes.py to support animated text overlays, including fade and move-in effects, with new parameters for animation kind, duration, easing, and opacity. The node now handles both single images and batches, animating the overlay for a configurable number of frames and holding the final pose for the remainder of the sequence.
2025-10-29 10:54:00 -03:00
scofano 22b183168d Change padding and shift properties to Integer 2025-10-29 08:43:52 -03:00
scofano 6f690c48f7 Merge branch 'main' of https://github.com/scofano/ComfyUI-Advanced-TextOverlay 2025-10-28 12:05:55 -03:00
scofano 824b888222 Parameters order changed 2025-10-28 12:05:51 -03:00
scofano d3d9bc6cff Fix image markdown syntax in README.md 2025-10-28 11:31:37 -03:00
scofano 23a4bb6a79 Update screenshot to be a clickable link 2025-10-28 11:31:04 -03:00
scofano bcfb465380 Added Font Alignment 2025-10-28 11:27:10 -03:00
scofano bdb7ce1d85 Merge branch 'main' of https://github.com/scofano/ComfyUI-Advanced-TextOverlay 2025-10-28 10:24:33 -03:00
scofano 673e9833eb Added Letter Spacing 2025-10-28 10:24:17 -03:00
scofano 923879040a Add letter spacing parameter to README 2025-10-28 10:23:28 -03:00
scofano bc5428af5d Added new Parameter - Letter Spacing 2025-10-28 10:21:45 -03:00
scofano ba0a48677e new ranges 2025-10-24 15:26:50 -03:00
scofano 830be708b6 Fix formatting and update key improvements section 2025-10-24 14:20:56 -03:00
scofano 7112fd3f09 Update image link in README.md 2025-10-24 14:20:37 -03:00
scofano 5a49ca6ef1 Update image link to use raw GitHub URL 2025-10-24 14:19:36 -03:00
scofano 40d045ec35 Add screenshot to README for text overlay
Added a screenshot to the README for better visualization.
2025-10-24 14:18:53 -03:00
scofano 9c74a78c98 updates 2025-10-24 14:16:34 -03:00
scofano dedd7b88f5 update 2025-10-24 13:58:11 -03:00
scofano 62617e530a Update README.md 2025-10-24 13:50:19 -03:00
6 changed files with 384 additions and 139 deletions
+62 -7
View File
@@ -1,12 +1,16 @@
# 🖋️ ComfyUI Advanced Text Overlay
**Repository:** [scofano/ComfyUI-Advanced-TextOverlay](https://github.com/scofano/ComfyUI-Advanced-TextOverlay)
**Forked from:** [munkyfoot/ComfyUI-TextOverlay](https://github.com/munkyfoot/ComfyUI-TextOverlay)
**Originally inspired by:** [mikkel/ComfyUI-text-overlay](https://github.com/mikkel/ComfyUI-text-overlay)
This node extends and refines the classic ComfyUI Text Overlay concept with a **re-engineered rendering engine**, **precise alignment logic**, **new customization parameters**, and **enhanced batch handling** — making it the most advanced text overlay solution available for ComfyUI.
---
![Advanced Text Overlay Screenshot](https://github.com/scofano/ComfyUI-Advanced-TextOverlay/blob/main/animated.gif)
## ✨ Key Improvements & New Features
@@ -18,7 +22,7 @@ This node extends and refines the classic ComfyUI Text Overlay concept with a **
### 🎨 2. Extended Styling and Layout Control
* **Line spacing** and **padding** parameters for advanced multi-line composition.
* **Line spacing**, **letter spacing** and **padding** parameters for advanced multi-line composition.
* **Independent X/Y shifting** for fine-tuned manual positioning.
* **Full hex color control** for both text fill and stroke.
* Supports transparent overlays for non-destructive compositing.
@@ -37,13 +41,63 @@ This node extends and refines the classic ComfyUI Text Overlay concept with a **
* Supports both `.ttf` and `.otf` formats.
* Graceful fallback to a system font if a specified font is unavailable.
### ⚙️ 5. Batch and Workflow Enhancements
### 🎞️ 5. New Animation Feature
* The node now supports smooth text animations via the new animations.py module.
* Each animation type manipulates opacity and/or position offsets frame-by-frame.
Supported Animation Types:
| Animation Kind | Description |
| -------------------- | --------------------------------------------------------- |
| **fade_in** | Gradually increases opacity from 0 → target. |
| **fade_out** | Gradually decreases opacity from target → 0. |
| **move_from_top** | Moves text upward from off-screen top into position. |
| **move_from_bottom** | Moves text downward from off-screen bottom into position. |
| **move_from_left** | Moves text from left edge into position. |
| **move_from_right** | Moves text from right edge into position. |
Easing Modes:
| Easing | Behavior |
| --------------- | ------------------------------------- |
| **linear** | Uniform speed. |
| **ease_in** | Starts slow, speeds up. |
| **ease_out** | Starts fast, slows down. |
| **ease_in_out** | Smooth acceleration and deceleration. |
Animation Parameters
| Parameter | Type | Description |
| ---------------------------- | ------- | ----------------------------------------------------------- |
| **animate** | Boolean | Enable or disable animation. |
| **animation_kind** | Enum | Selects animation type (`fade_in`, `move_from_left`, etc.). |
| **animation_frames** | Integer | Number of frames to complete the animation. |
| **animation_ease** | Enum | Easing curve for smooth motion. |
| **animation_opacity_target** | Float | Target opacity multiplier for animation. |
Behavior:
* For single images, the node outputs either one static frame or an animated sequence.
* For batches/videos, animation applies over the first animation_frames frames, then holds the final pose.
Technical Summary:
The animation module provides:
* progress(t, T_minus1, ease): easing interpolation over frames.
* compute_opacity(kind, p, target): calculates alpha fade transitions.
* compute_offsets(kind, p, img_w, img_h): determines X/Y displacement for move animations.
These functions are integrated directly into the node’s rendering loop for seamless animated overlays
### ⚙️ 6. Batch and Workflow Enhancements
* Native **batch support** — automatically applies consistent overlay logic across all input images.
* Optimized performance and memory use when processing large image sets.
* Node parameters persist and preview correctly in ComfyUI sessions.
### 🧩 6. Developer & Extensibility Features
### 🧩 7. Developer & Extensibility Features
* Modularized code for easier maintenance and feature additions.
* Clean separation between text layout and rendering logic.
@@ -96,12 +150,13 @@ To install the **Advanced Text Overlay** node:
| **fill_color_hex** | String | Text fill color in hex format (e.g., `#FFFFFF`). |
| **stroke_color_hex** | String | Stroke (outline) color in hex format (e.g., `#000000`). |
| **stroke_thickness** | Float | Thickness of the text outline. |
| **padding** | Float | Additional space around the text block. Useful for multi-line layouts. |
| **padding** | Integer | Additional space around the text block. Useful for multi-line layouts. |
| **horizontal_alignment** | Enum | `left`, `center`, or `right` horizontal alignment. |
| **vertical_alignment** | Enum | `top`, `middle`, or `bottom` vertical alignment. |
| **x_shift** | Float | Horizontal offset adjustment (pixels). |
| **y_shift** | Float | Vertical offset adjustment (pixels). |
| **x_shift** | Integer | Horizontal offset adjustment (pixels). |
| **y_shift** | Integer | Vertical offset adjustment (pixels). |
| **line_spacing** | Float | Vertical spacing between lines of text. |
| **letter_spacing** | Float | Horizontal spacing between letters. |
---
@@ -115,4 +170,4 @@ The node outputs the modified image (or batch of images) with the specified text
* Original concept by [mikkel](https://github.com/mikkel/ComfyUI-text-overlay)
* Fork base by [munkyfoot](https://github.com/munkyfoot/ComfyUI-TextOverlay)
* Advanced reimplementation and feature expansion by [scofano](https://github.com/scofano)
* Advanced reimplementation and feature expansion by [scofano](https://github.com/scofano)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

+48
View File
@@ -0,0 +1,48 @@
from typing import Tuple
# Easing -------------------------------------------------------------
def progress(t: int, T_minus1: int, ease: str) -> float:
if T_minus1 <= 0:
return 1.0
x = float(t) / float(T_minus1)
if ease == "linear":
return x
if ease == "ease_in":
return x * x
if ease == "ease_out":
return 1 - (1 - x) * (1 - x)
if ease == "ease_in_out":
if x < 0.5:
return 2 * x * x
return 1 - (-2 * x + 2) ** 2 / 2
return x
# Opacity ------------------------------------------------------------
def compute_opacity(kind: str, p: float, target: float) -> float:
"""
kind: 'fade_in' | 'fade_out' | others
p in [0,1], target in [0,1]
"""
target = max(0.0, min(1.0, float(target)))
if kind == "fade_out":
return target * (1.0 - p)
if kind == "fade_in":
return target * p
return target
# Offsets ------------------------------------------------------------
def compute_offsets(kind: str, p: float, img_w: int, img_h: int) -> Tuple[int, int]:
"""
Move from off-screen-ish towards the final position.
p=0 => max offset, p=1 => 0 offset.
"""
dx = dy = 0.0
if kind == "move_from_left":
dx = -(img_w * (1.0 - p) * 0.5)
elif kind == "move_from_right":
dx = (img_w * (1.0 - p) * 0.5)
elif kind == "move_from_top":
dy = -(img_h * (1.0 - p) * 0.5)
elif kind == "move_from_bottom":
dy = (img_h * (1.0 - p) * 0.5)
return int(round(dx)), int(round(dy))
+258 -132
View File
@@ -1,34 +1,31 @@
import os
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw, ImageFont, ImageChops
# Relative import so it works as a package module in ComfyUI
from . import animations
class TextOverlay:
"""
Text overlay node with:
- Fill/stroke alpha
- Shadow and background box
- Shadow and background box (both animate with opacity)
- Pixel-perfect stroke alignment for MULTILINE text
- Even stroke width for crisper edges
- Default vertical_alignment = 'middle'
- Batch animation: uses the first `animation_frames` frames for the animation,
then holds the final pose for the rest of the video.
- Mask animations removed. Renamed kinds: fade_in/fade_out, move_from_*.
"""
_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)
@@ -38,35 +35,47 @@ class TextOverlay:
# font, font-size, font color, font alpha
"font": ("STRING", {"default": "ariblk.ttf"}),
"font_size": ("INT", {"default": 32, "min": 1, "max": 9999, "step": 1}),
"letter_spacing": ("FLOAT", {"default": 0.0, "min": -10.0, "max": 50.0, "step": 0.5}),
"font_alignment": (cls._horizontal_alignments, {"default": "center"}),
"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}),
# padding, alignment, offsets
"padding": ("INT", {"default": 16, "min": 0, "max": 1024, "step": 1}),
"vertical_alignment": (cls._vertical_alignments, {"default": "middle"}),
"y_shift": ("INT", {"default": 0, "min": -256, "max": 256, "step": 1}),
"y_shift": ("INT", {"default": 0, "min": -1024, "max": 1024, "step": 1}),
"horizontal_alignment": (cls._horizontal_alignments, {"default": "center"}),
"x_shift": ("INT", {"default": 0, "min": -256, "max": 256, "step": 1}),
"x_shift": ("INT", {"default": 0, "min": -1024, "max": 1024, "step": 1}),
"line_spacing": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 50.0, "step": 0.5}),
# all strokes
# 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
# background box
"bg_enable": ("BOOLEAN", {"default": False}),
"bg_padding": ("INT", {"default": 8, "min": 0, "max": 256, "step": 1}),
"bg_padding": ("INT", {"default": 8, "min": 0, "max": 1024, "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
"shadow_enable": ("BOOLEAN", {"default": False}),
"shadow_distance": ("INT", {"default": 4, "min": -50, "max": 50, "step": 1}),
"shadow_distance": ("INT", {"default": 3, "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"}),
"shadow_alpha": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}),
# --- animation controls ---
"animate": ("BOOLEAN", {"default": False}),
"animation_kind": ([
"fade_in", "fade_out",
"move_from_top", "move_from_bottom", "move_from_left", "move_from_right",
], {"default": "fade_in"}),
"animation_frames": ("INT", {"default": 32, "min": 1, "max": 1000, "step": 1}),
"animation_ease": (["linear","ease_in","ease_out","ease_in_out"], {"default": "ease_in_out"}),
"animation_opacity_target": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}),
}
}
@@ -83,116 +92,112 @@ class TextOverlay:
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
font_path = font
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).
"""
def _wrap_lines(self, draw, text, font, max_width, padding, letter_spacing):
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):
char_count = max(0, len(candidate) - 1)
candidate_px = draw.textlength(candidate, font=font) + char_count * letter_spacing
if candidate_px <= (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.
h_align, v_align, x_shift, y_shift, line_spacing, letter_spacing, font_size, use_cache):
try:
current_font_id = (font.getname(), getattr(font, "path", None))
except Exception:
current_font_id = (None, None)
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)
need_recompute = True
if hasattr(self, "_cached") and self._cached is not None and use_cache:
(*_, cached_letter_spacing, cached_stroke_width,
cached_text, cached_font_id, cached_font_size, cached_padding, cached_line_spacing) = self._cached
if (cached_letter_spacing == letter_spacing and
cached_stroke_width == stroke_width and
cached_text == text and
cached_font_id == current_font_id and
cached_font_size == font_size and
cached_padding == padding and
cached_line_spacing == line_spacing):
need_recompute = False
if need_recompute:
lines = self._wrap_lines(draw, text, font, img_w, padding, letter_spacing)
widths, heights, tops = [], [], []
lefts, rights = [], []
lefts, rights_sp = [], []
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)
w = (r - l)
h = (b - t)
extra = max(0, len(ln) - 1) * letter_spacing
widths.append(w + extra)
heights.append(h)
tops.append(t)
lefts.append(l)
rights.append(r)
rights_sp.append(r + extra)
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
max_right = max(rights_sp) if rights_sp else 0
block_w = max_right - min_left if rights_sp 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
min_top = min(tops) if tops else 0
# cache results to reuse on subsequent frames
self._cached = (lines, widths, heights, tops, min_top, min_left, block_w, block_h)
self._cached = (
lines, widths, heights, tops, min_top, min_left,
block_w, block_h,
letter_spacing, stroke_width,
text, current_font_id, font_size, padding, line_spacing
)
lines, widths, heights, tops, min_top, min_left, block_w, block_h = self._cached
(lines, widths, heights, tops, min_top, min_left,
block_w, block_h, _cached_letter_spacing, _cached_stroke_width, *_) = 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
else:
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
else:
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))
@@ -204,6 +209,7 @@ class TextOverlay:
text,
all_caps,
font_size,
letter_spacing,
font,
fill_color_hex,
fill_alpha,
@@ -226,42 +232,52 @@ class TextOverlay:
shadow_color_hex,
shadow_alpha,
shadow_distance,
font_alignment,
use_cache=False,
opacity_scale=1.0,
dx=0,
dy=0
):
# Prepare
if image.mode != "RGBA":
image = image.convert("RGBA")
self._loaded_font = self._load_font(font, font_size)
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)
opacity_scale = max(0.0, min(1.0, float(opacity_scale)))
fill_alpha = max(0.0, min(1.0, float(fill_alpha) * opacity_scale))
stroke_alpha = max(0.0, min(1.0, float(stroke_alpha) * opacity_scale))
x_shift = int(round(x_shift + dx))
y_shift = int(round(y_shift + dy))
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,
image.width, image.height, draw, text, loaded_font, sw,
padding, horizontal_alignment, vertical_alignment, x_shift, y_shift,
line_spacing, use_cache
line_spacing, letter_spacing, font_size, 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)
def _line_offset(i):
if font_alignment == "left":
return 0
elif font_alignment == "center":
return int(round((block_w - widths[i]) / 2))
else:
return int(round(block_w - widths[i]))
# Background (animated alpha)
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)
ba = int(max(0.0, min(1.0, float(bg_alpha) * opacity_scale)) * 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))
@@ -272,22 +288,26 @@ class TextOverlay:
od.rectangle(rect, fill=(br, bgc, bb, ba))
image = Image.alpha_composite(image, overlay)
# Shadow (per-line, before stroke/fill)
# Shadow (animated alpha)
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)
sh_a = int(max(0.0, min(1.0, float(shadow_alpha) * opacity_scale)) * 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))
for i, (ln, h) in enumerate(zip(lines, heights)):
xx = x_draw + _line_offset(i)
for ch in ln:
od.text((xx + sdx, yy + sdy), ch, font=loaded_font,
fill=(sh_r, sh_g, sh_b, sh_a))
xx += draw.textlength(ch, font=loaded_font) + letter_spacing
yy += int(round(h + line_spacing))
image = Image.alpha_composite(image, overlay)
# Stroke + Fill (per-line; identical geometry/positions)
# Stroke + Fill
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)
@@ -297,26 +317,20 @@ class TextOverlay:
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),
)
for i, (ln, h) in enumerate(zip(lines, heights)):
xx = x_draw + _line_offset(i)
for ch in ln:
if sw > 0 and sa > 0:
od.text((xx, yy), ch, font=loaded_font,
fill=(sr, sg, sb, sa),
stroke_width=sw, stroke_fill=(sr, sg, sb, sa))
if fa > 0:
od.text((xx, yy), ch, font=loaded_font, fill=(fr, fg, fb, fa))
xx += draw.textlength(ch, font=loaded_font) + letter_spacing
yy += int(round(h + line_spacing))
image = Image.alpha_composite(image, overlay)
return image.convert("RGB")
# ---------------- Comfy entrypoint ----------------
@@ -325,8 +339,9 @@ class TextOverlay:
self,
image,
text,
all_caps, # NEW
all_caps,
font_size,
letter_spacing,
font,
fill_color_hex,
fill_alpha,
@@ -349,49 +364,160 @@ class TextOverlay:
shadow_color_hex,
shadow_alpha,
shadow_distance,
font_alignment,
animate=False,
animation_kind='fade_in',
animation_frames=24,
animation_ease='ease_out',
animation_opacity_target=1.0
):
"""Handles both single and batch image processing (with layout caching across frames)."""
"""
Single image (H,W,C):
- animate=False -> one output image
- animate=True -> returns exactly `animation_frames` frames
# Single image
Batch (B,H,W,C) video:
- animate=False -> draw per-frame with no animation changes
- animate=True -> animation starts at frame 0 and completes at frame `animation_frames-1`.
From then on, the final pose is held until frame B-1.
"""
# Single image (H, W, C)
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))
if not animate:
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, font,
font_size, letter_spacing, 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
shadow_enable, shadow_color_hex, shadow_alpha, shadow_distance, font_alignment,
use_cache=False,
)
out = np.array(out_img).astype(np.float32) / 255.0
return (torch.tensor(out),)
T = max(1, int(animation_frames))
outs = []
# Prime layout cache
_ = self.draw_text(
pil_img, text, all_caps,
font_size, letter_spacing, font,
fill_color_hex, 1.0,
stroke_enable,
stroke_color_hex, 1.0, 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, font_alignment,
use_cache=False,
)
use_cache = True
for t_idx in range(T):
p = animations.progress(t_idx, max(1, T - 1), animation_ease)
op = animations.compute_opacity(animation_kind, p, float(animation_opacity_target))
dx, dy = animations.compute_offsets(animation_kind, p, pil_img.width, pil_img.height)
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, letter_spacing, 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, font_alignment,
use_cache=use_cache,
opacity_scale=op,
dx=dx,
dy=dy,
)
outs.append(np.array(out_img).astype(np.float32) / 255.0)
use_cache = True
return (torch.tensor(np.stack(outs)),)
# Batch (B, H, W, C)
if not (hasattr(image, "shape") and len(image.shape) == 4):
raise ValueError("Unsupported image tensor shape")
B, H, W, C = image.shape
if not animate:
out_list = []
for i in range(B):
np_img = image[i].cpu().numpy()
pil_img = Image.fromarray((np_img * 255).astype(np.uint8))
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, letter_spacing, 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, font_alignment,
use_cache=False,
)
out_list.append(np.array(out_img).astype(np.float32) / 255.0)
return (torch.tensor(np.stack(out_list)),)
# Animated batch: animate on frames [0 .. T-1], then hold on frames [T .. B-1]
T = max(1, int(animation_frames))
# Prime cache once using first frame
np_img0 = image[0].cpu().numpy()
pil_img0 = Image.fromarray((np_img0 * 255).astype(np.uint8))
_ = self.draw_text(
pil_img0, text, all_caps,
font_size, letter_spacing, font,
fill_color_hex, 1.0,
stroke_enable,
stroke_color_hex, 1.0, 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, font_alignment,
use_cache=False,
)
use_cache = True
out_list = []
for i in range(B):
np_img = image[i].cpu().numpy()
pil_img = Image.fromarray((np_img * 255).astype(np.uint8))
eff_t = min(i, T - 1) # frames beyond T-1 hold the last pose
p = animations.progress(eff_t, max(1, T - 1), animation_ease)
op = animations.compute_opacity(animation_kind, p, float(animation_opacity_target))
dx, dy = animations.compute_offsets(animation_kind, p, pil_img.width, pil_img.height)
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, letter_spacing, 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, font_alignment,
use_cache=use_cache,
opacity_scale=op,
dx=dx,
dy=dy,
)
out_list.append(np.array(out_img).astype(np.float32) / 255.0)
return (torch.tensor(np.stack(out_list)),)
NODE_CLASS_MAPPINGS = {"Advanced Text Overlay": TextOverlay}
+13
View File
@@ -0,0 +1,13 @@
# pyproject.toml
[project]
name = "advanced-textoverlay" # Unique and immutable node identifier.
description = "Advanced Text Overlay Node for ComfyUI featuring rebuilt rendering logic, precise alignment, custom fonts, and extended styling controls."
version = "1.3.0" # Semantic versioning
dependencies = [] # Populated from requirements.txt if present.
[project.urls]
Repository = "https://github.com/scofano/ComfyUI-Advanced-TextOverlay"
[tool.comfy]
PublisherId = "dansco"
DisplayName = "Advanced Text Overlay"
+3
View File
@@ -0,0 +1,3 @@
numpy
torch
Pillow