Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89992997c5 | ||
|
|
67d3c6d9d4 | ||
|
|
29e0bbdad6 | ||
|
|
dbf6449949 | ||
|
|
405e4b4d19 | ||
|
|
22b183168d | ||
|
|
6f690c48f7 | ||
|
|
824b888222 | ||
|
|
d3d9bc6cff | ||
|
|
23a4bb6a79 | ||
|
|
bcfb465380 | ||
|
|
bdb7ce1d85 | ||
|
|
673e9833eb | ||
|
|
923879040a | ||
|
|
bc5428af5d | ||
|
|
ba0a48677e | ||
|
|
830be708b6 | ||
|
|
7112fd3f09 | ||
|
|
5a49ca6ef1 | ||
|
|
40d045ec35 | ||
|
|
9c74a78c98 | ||
|
|
dedd7b88f5 | ||
|
|
62617e530a |
@@ -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.
|
||||
|
||||
|
||||
---
|
||||

|
||||
|
||||
## ✨ 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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 629 KiB |
@@ -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))
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
numpy
|
||||
torch
|
||||
Pillow
|
||||
Reference in New Issue
Block a user