31 Commits
Author SHA1 Message Date
scofano 0c64e93180 removed gif
updated requirements
2025-11-24 22:14:54 -03:00
scofano 26f188f0e0 Update README.md 2025-11-24 22:12:25 -03:00
scofano a73d04dfe8 Update README.md 2025-11-24 22:11:50 -03:00
scofano 5d7377287e Update README.md 2025-11-24 22:10:40 -03:00
scofano 95e929863d Update README.md 2025-11-24 22:10:09 -03:00
scofano a0eeebbfbf Update README.md 2025-11-24 22:08:12 -03:00
scofano f93908247d Update README.md 2025-11-24 22:07:48 -03:00
scofano 2e566fc2e8 Update README.md 2025-11-24 22:06:42 -03:00
scofano 93f28874d5 Update README.md 2025-11-24 22:06:01 -03:00
scofano d7708c7cd9 Update README.md 2025-11-24 22:05:03 -03:00
scofano 6d1d321691 Bump version to 1.4.0
Update project version in pyproject.toml from 1.3.0 to 1.4.0 to reflect new changes or improvements.
2025-11-24 22:04:00 -03:00
scofano e6441bbae3 Revise README with expanded feature overview
The README has been updated to provide a clearer, more detailed summary of the module's capabilities, including new sections for features, input parameters, and usage instructions. The documentation now highlights advanced layout, animation, batch processing, and video support, making it easier for users to understand and utilize the node.
2025-11-24 22:03:02 -03:00
scofano 76c82b7af5 Add pause before text overlay animation
Introduces a pause option before starting text overlay for both image and video nodes. The new parameters 'pause_frames_before_start' (frames) and 'pause_seconds_before_start' (seconds) allow delaying the overlay animation, with logic updated to skip overlay during the pause period. Refactors batch and video processing to respect the pause, and updates input types accordingly.
2025-11-24 21:59:22 -03:00
scofano 03b0096be6 Add option to delete original video after processing
Introduces a 'delete_original' boolean parameter to the TextOverlayVideo node. When enabled, the original input video is deleted after processing is complete. This helps manage disk space when processing large numbers of videos.
2025-11-20 16:11:34 -03:00
scofano 86f8686d30 Add audio muxing to processed videos using ffmpeg
After processing a video, the original audio track is now muxed into the output file using ffmpeg if available. This ensures the resulting video retains its original audio, and the process fails gracefully if ffmpeg is not present or an error occurs.
2025-11-20 12:47:52 -03:00
scofano 63253a9267 Update filename prefix and node mapping for video overlay
Changed the default filename prefix for video overlays to 'TxtOver' and updated the node mapping name from 'Advanced Text Overlay Video' to 'Advanced Text Overlay - Video' for consistency.
2025-11-20 11:51:43 -03:00
scofano 28f8d58532 Add Advanced Text Overlay Video node
Introduces the TextOverlayVideo class, enabling text overlay processing on video files with animation support. The new node processes each video frame using the existing text overlay logic, writes the result to a new video file, and provides progress updates via tqdm and ComfyUI's ProgressBar. Also updates the node category and expands NODE_CLASS_MAPPINGS to include the new video node.
2025-11-20 11:45:26 -03:00
scofano 1dd948580c Enhance README with animation types and descriptions
Added detailed descriptions for supported animation types.
2025-10-29 11:33:22 -03:00
scofano a58e0bf17d Update README to use image for Fade_in animation
Replaced Giphy iframe with an image for the Fade_in animation.
2025-10-29 11:30:25 -03:00
scofano 269e3a4f5b Update README to use iframe for Fade_in animation
Replaced static image link with an iframe for the Fade_in animation.
2025-10-29 11:29:20 -03:00
scofano 95dc2adf9a Change animation type formatting in README
Updated animation type representation in README.
2025-10-29 11:28:08 -03:00
scofano b02167cfda Enhance README with Fade_in animation details
Added HTML formatting for Fade_in animation example.
2025-10-29 11:25:27 -03:00
scofano 58f79cf91f Update README with fade_in GIF and remove text
Removed detailed animation descriptions and added a GIF for 'fade_in'.
2025-10-29 11:24:51 -03:00
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
6 changed files with 721 additions and 186 deletions
+141 -70
View File
@@ -6,113 +6,184 @@
**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.
This module provides the **most feature‑rich and precise text overlay system available for ComfyUI**, including:
* Completely re‑engineered layout engine
* Pixel‑perfect multiline stroke alignment
* Opacity‑aware backgrounds and shadows
* Letter/line spacing, padding, and alignment controls
* Full animation engine (fade + directional movement)
* Batch-aware rendering with smart caching
* Full video processing node with audio‑preserving re‑mux
---
![Advanced Text Overlay Screenshot](https://github.com/scofano/ComfyUI-Advanced-TextOverlay/blob/main/animated.gif)
## ✨ Key Features
## ✨ Key Improvements & New Features
### ✔️ 1. Rebuilt Rendering Engine
### 🧱 1. Rebuilt Rendering Engine
* Accurate bounding boxes for multiline text
* Consistent stroke widths (even‑pixel enforcement)
* Crisp, stable text edges with alpha‑aware compositing
* Fully restructured text rendering logic for higher precision and cleaner anti-aliasing.
* Pixel-accurate bounding box calculations ensure text aligns perfectly to the intended position.
* Consistent stroke behavior across font sizes with improved blending and color stability.
### ✔️ 2. Advanced Layout and Typography
### 🎨 2. Extended Styling and Layout Control
* **Letter spacing** and **line spacing**
* **Padding** around the text block
* Horizontal and vertical alignment: `left` / `center` / `right`, `top` / `middle` / `bottom`
* **Per‑axis offset** (`x_shift`, `y_shift`)
* Automatic multiline wrapping based on image width
* **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.
### ✔️ 3. Styling Options
### 🧭 3. Smarter Alignment and Positioning
* Fill + alpha
* Stroke + alpha
* Shadow + alpha + offset
* Rounded background box with padding, color, radius, and alpha
* Expanded support for:
### ✔️ 4. Animation System
* Horizontal alignment: `left`, `center`, `right`
* Vertical alignment: `top`, `middle`, `bottom`
* Reliable anchor-based alignment even on varying resolutions and batch images.
Supports:
### 🔠 4. Font Management
* `fade_in`
* `fade_out`
* `move_from_top`
* `move_from_bottom`
* `move_from_left`
* `move_from_right`
* Custom fonts can be loaded directly from the repo’s `/fonts` directory.
* Supports both `.ttf` and `.otf` formats.
* Graceful fallback to a system font if a specified font is unavailable.
With easing:
### ⚙️ 5. Batch and Workflow Enhancements
* `linear`
* `ease_in`
* `ease_out`
* `ease_in_out`
* 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.
Animation Parameters:
### 🧩 6. Developer & Extensibility Features
* `animation_frames`
* `animation_opacity_target`
* `pause_frames_before_start`
* Modularized code for easier maintenance and feature additions.
* Clean separation between text layout and rendering logic.
* Ready for further extensions such as shadow layers, gradient text, or dynamic variable injection.
### ✔️ 5. Batch-Smart Processing
* Automatically caches layout on first frame
* Maintains perfect consistency across all frames
* For animated batches: animates through first *N* frames, then holds the final pose
### ✔️ 6. Full Video Support
The **Advanced Text Overlay – Video** node:
* Reads a video frame‑by‑frame
* Applies the same overlay logic
* Writes a new MP4
* **Automatically re‑injects the original audio track with ffmpeg**
* Optional `delete_original` flag
---
## ⚙️ Installation
## 📥 Installation
To install the **Advanced Text Overlay** node:
Clone the repository inside your ComfyUI `custom_nodes` directory:
1. Locate your ComfyUI `custom_nodes` directory.
Typically found at:
```bash
git clone https://github.com/scofano/ComfyUI-Advanced-TextOverlay
```
```
ComfyUI/custom_nodes/
```
2. Clone this repository into that directory:
```bash
git clone https://github.com/scofano/ComfyUI-Advanced-TextOverlay
```
3. Restart ComfyUI.
The node will appear under the **`image/text`** category as **“Advanced Text Overlay”**.
Restart ComfyUI — the nodes will appear under **Advanced Text Overlay**.
---
## 🚀 Usage
1. Add the **Advanced Text Overlay** node to your ComfyUI workflow.
2. Connect your image source to the node’s **image input**.
3. Configure text and style parameters:
### **Image Node: `Advanced Text Overlay`**
* Text content
* Font and size
* Fill and stroke colors
* Alignment, spacing, and offset adjustments
4. Connect the node’s output to your desired destination (e.g., preview, save image).
Connect an image or batch → configure text parameters → render.
### **Video Node: `Advanced Text Overlay – Video`**
Provide a path to a video → configure overlay → output is written to ComfyUI's output directory.
---
## 🔧 Input Parameters
| Parameter | Type | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------- |
| **image** | Image / Batch | Input image(s) to overlay text onto. |
| **text** | String | The text content to render on the image. Supports multi-line text. |
| **font** | String | Font name or filename (e.g., `arial.ttf`). Looks in `/fonts` or system fonts. |
| **font_size** | Integer | Size of the text in pixels. |
| **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. |
| **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). |
| **line_spacing** | Float | Vertical spacing between lines of text. |
| **letter_spacing** | Float | Horizontal spacing between letters. |
### Text & Typography
| Parameter | Description |
| ---------------- | ----------------------------------------- |
| `text` | Text to draw; supports multiline `\n` |
| `all_caps` | Force uppercase |
| `font` | Font name or file; auto-searches `/fonts` |
| `font_size` | Pixel size |
| `letter_spacing` | Per-character spacing |
| `line_spacing` | Spacing between lines |
### Color & Stroke
| Parameter | Description |
| ------------------ | ---------------------------------------- |
| `fill_color_hex` | Hex fill color |
| `fill_alpha` | Opacity 0–1 |
| `stroke_enable` | Toggle outline |
| `stroke_color_hex` | Hex outline color |
| `stroke_alpha` | Outline opacity |
| `stroke_thickness` | Relative thickness (scaled by font size) |
### Layout & Position
| Parameter | Description |
| ---------------------- | --------------------------- |
| `padding` | Padding around text block |
| `horizontal_alignment` | `left` / `center` / `right` |
| `vertical_alignment` | `top` / `middle` / `bottom` |
| `x_shift`, `y_shift` | Pixel offsets |
### Background Box
| Parameter | Description |
| -------------- | ------------------------- |
| `bg_enable` | Toggle background box |
| `bg_padding` | Padding around text block |
| `bg_radius` | Rounded corner radius |
| `bg_color_hex` | Hex color |
| `bg_alpha` | Opacity |
### Shadow
| Parameter | Description |
| ------------------ | ------------- |
| `shadow_enable` | Toggle shadow |
| `shadow_distance` | Pixel offset |
| `shadow_color_hex` | Shadow color |
| `shadow_alpha` | Opacity |
### Animation
| Parameter | Description |
| --------------------------- | ------------------------------- |
| `animate` | Enable animation |
| `animation_kind` | Fade or movement animation |
| `animation_frames` | How many frames animation lasts |
| `animation_opacity_target` | Target alpha |
| `animation_ease` | Easing curve |
| `pause_frames_before_start` | Delay before animation begins |
### Video-Specific Parameters
| Parameter | Description |
| ---------------------------- | ------------------------------------ |
| `video_path` | Input video |
| `filename_prefix` | Naming prefix for output |
| `delete_original` | Remove input after processing |
| `pause_seconds_before_start` | Pause duration (converted to frames) |
---
## 🖼️ Output
## 🧪 Output
The node outputs the modified image (or batch of images) with the specified text overlay applied according to the given configuration.
* Image node → modified image/batch
* Video node → MP4 written to ComfyUI output directory, with audio preserved
---
BIN
View File
Binary file not shown.

Before

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))
+528 -114
View File
@@ -1,34 +1,46 @@
import os
import subprocess
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
import imageio.v2 as imageio
try:
from tqdm import tqdm
except ImportError:
# If tqdm isn't installed, fall back to a no-op wrapper
def tqdm(x, **kwargs):
return x
try:
from comfy.utils import ProgressBar
except Exception:
ProgressBar = None
# 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)
@@ -43,7 +55,7 @@ class TextOverlay:
"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, 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": -1024, "max": 1024, "step": 1}),
@@ -51,30 +63,43 @@ class TextOverlay:
"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": 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": 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"}),
# how long to wait before starting overlay (frames for this node, seconds for the Video node)
"pause_frames_before_start": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "batch_process"
CATEGORY = "image/text"
CATEGORY = "Advanced Text Overlay"
# ---------------- helpers ----------------
@@ -85,14 +110,13 @@ 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:
@@ -100,26 +124,17 @@ class TextOverlay:
return ImageFont.load_default()
def _wrap_lines(self, draw, text, font, max_width, padding, letter_spacing):
"""
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:
if para == "":
out_lines.append("")
continue
tokens = re.findall(r"\S+|\s+", para)
line = ""
for tok in tokens:
candidate = line + tok
# include tracking in the width test
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):
@@ -131,52 +146,33 @@ class TextOverlay:
else:
out_lines.append(line)
line = tok.lstrip()
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, letter_spacing, font_size, use_cache):
"""
Manual multiline layout so stroke/fill line positions are identical.
IMPORTANT: We compute the visual extents using per-line bbox plus letter spacing,
so background boxes and centering reflect tracking.
Returns:
(lines, widths, heights, tops, min_top, min_left, block_w, block_h, x0, visual_top_y)
"""
# derive a stable identifier for the font face (works across new FreeTypeFont instances)
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)
need_recompute = True
if self._cached is not None and use_cache:
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
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
):
cached_line_spacing == line_spacing):
need_recompute = False
if need_recompute:
# wrap with spacing-aware width checks
lines = self._wrap_lines(draw, text, font, img_w, padding, letter_spacing)
widths, heights, tops = [], [], []
lefts, rights_sp = [], []
for ln in lines:
@@ -196,7 +192,6 @@ class TextOverlay:
block_h = sum(heights) + (len(heights) - 1) * line_spacing if heights else 0
min_top = min(tops) if tops else 0
# cache results to reuse on subsequent frames (append both letter_spacing and stroke_width)
self._cached = (
lines, widths, heights, tops, min_top, min_left,
block_w, block_h,
@@ -204,29 +199,23 @@ class TextOverlay:
text, current_font_id, font_size, padding, line_spacing
)
# unpack cached values
(lines, widths, heights, tops, min_top, min_left,
block_w, block_h, cached_letter_spacing, cached_stroke_width, *_) = self._cached
block_w, block_h, _cached_letter_spacing, _cached_stroke_width, *_) = self._cached
# Horizontal anchor for the *visual* block (uses min_left/max_right w/ spacing)
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))
@@ -263,51 +252,50 @@ class TextOverlay:
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, 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
# Per-line offsets to align each line within the block
def _line_offset(i):
if font_alignment == "left":
return 0
elif font_alignment == "center":
return int(round((block_w - widths[i]) / 2))
else: # "right"
else:
return int(round(block_w - widths[i]))
# Background box (perfectly aligned with visual extents)
# 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))
@@ -318,26 +306,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 i, (ln, h) in enumerate(zip(lines, heights)):
xx = x_draw + _line_offset(i) # NEW
xx = x_draw + _line_offset(i)
for ch in ln:
od.text((xx + sdx, yy + sdy), ch, font=self._loaded_font,
od.text((xx + sdx, yy + sdy), ch, font=loaded_font,
fill=(sh_r, sh_g, sh_b, sh_a))
xx += draw.textlength(ch, font=self._loaded_font) + letter_spacing
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)
@@ -348,15 +336,15 @@ class TextOverlay:
yy = first_line_baseline_y
for i, (ln, h) in enumerate(zip(lines, heights)):
xx = x_draw + _line_offset(i) # NEW
xx = x_draw + _line_offset(i)
for ch in ln:
if sw > 0 and sa > 0:
od.text((xx, yy), ch, font=self._loaded_font,
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=self._loaded_font, fill=(fr, fg, fb, fa))
xx += draw.textlength(ch, font=self._loaded_font) + letter_spacing
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)
@@ -395,35 +383,37 @@ class TextOverlay:
shadow_alpha,
shadow_distance,
font_alignment,
animate=False,
animation_kind='fade_in',
animation_frames=24,
animation_ease='ease_out',
animation_opacity_target=1.0,
pause_frames_before_start=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 after `pause_before_start` frames,
then runs for up to `animation_frames` frames.
Frames after that hold the final pose.
NOTE: This node has no FPS info; here `pause_before_start` is effectively
in frames (not seconds). For 30fps video and a 1s pause, use 30.
"""
pause_frames = max(0, int(pause_frames_before_start))
# 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, 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 = 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:
# No timeline here → pause_before_start is ignored
out_img = self.draw_text(
pil_img, text, all_caps,
font_size, letter_spacing, font,
@@ -433,11 +423,435 @@ class TextOverlay:
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, # 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 (fills self._cached)
_ = 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):
if t_idx < pause_frames:
# Before the pause ends: no text overlay at all
out_img = pil_img.copy()
else:
active_frames = max(1, T - pause_frames)
local_idx = t_idx - pause_frames
eff_local = min(local_idx, active_frames - 1)
p = animations.progress(eff_local, max(1, active_frames - 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)),)
NODE_CLASS_MAPPINGS = {"Advanced Text Overlay": TextOverlay}
# 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
# Non-animated batch
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))
if i < pause_frames:
# Pass-through until pause is over
out_img = pil_img
else:
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 [pause_frames .. pause_frames+T-1],
# then hold on frames after that
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))
if i < pause_frames:
# No overlay yet
out_img = pil_img
else:
eff_idx = i - pause_frames
eff_t = min(eff_idx, T - 1) # frames beyond animation 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)),)
class TextOverlayVideo:
"""
Video version of Advanced Text Overlay.
- Input: full video file path (STRING).
- Output: STRING with full path of processed video in ComfyUI's output folder.
- Uses the same text / animation controls as TextOverlay, and animates
over the first `animation_frames` frames, then holds the final pose.
"""
_horizontal_alignments = TextOverlay._horizontal_alignments
_vertical_alignments = TextOverlay._vertical_alignments
# Make this an output node so the prompt has outputs
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
base = TextOverlay.INPUT_TYPES()["required"].copy()
base.pop("image")
# 🔧 rename the pause key for the video node
base["pause_seconds_before_start"] = base.pop("pause_frames_before_start")
required = {
"video_path": ("STRING", {"multiline": False, "default": ""}),
"filename_prefix": ("STRING", {"default": "TxtOver"}),
"delete_original": ("BOOLEAN", {"default": False}),
}
required.update(base)
return {"required": required}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video_path",)
FUNCTION = "process_video"
CATEGORY = "Advanced Text Overlay"
def _get_output_dir(self):
# Default ComfyUI output folder if available
try:
import folder_paths
return folder_paths.get_output_directory()
except Exception:
# Fallback if run standalone
out_dir = os.path.join(os.getcwd(), "output")
os.makedirs(out_dir, exist_ok=True)
return out_dir
def _make_unique_path(self, out_dir, filename_prefix, src_path):
src_base = os.path.splitext(os.path.basename(src_path))[0]
base_name = f"{filename_prefix}_{src_base}.mp4"
out_full = os.path.join(out_dir, base_name)
idx = 1
while os.path.exists(out_full):
base_name = f"{filename_prefix}_{src_base}_{idx}.mp4"
out_full = os.path.join(out_dir, base_name)
idx += 1
return out_full
def process_video(
self,
video_path,
filename_prefix,
delete_original,
text,
all_caps,
font,
font_size,
letter_spacing,
font_alignment,
fill_color_hex,
fill_alpha,
padding,
vertical_alignment,
y_shift,
horizontal_alignment,
x_shift,
line_spacing,
stroke_enable,
stroke_color_hex,
stroke_thickness,
stroke_alpha,
bg_enable,
bg_padding,
bg_radius,
bg_color_hex,
bg_alpha,
shadow_enable,
shadow_distance,
shadow_color_hex,
shadow_alpha,
animate,
animation_kind,
animation_frames,
animation_ease,
animation_opacity_target,
pause_seconds_before_start,
):
"""
Reads the video frame by frame, applies the same text overlay logic as the
batch TextOverlay, and writes a new video file.
Returns the full path string to the new video.
Shows progress in:
- ComfyUI (ProgressBar)
- Console (tqdm)
After writing the processed video, we mux the original audio track
from `video_path` into the output file using ffmpeg, if available.
"""
if not video_path or not os.path.exists(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
out_dir = self._get_output_dir()
out_path = self._make_unique_path(out_dir, filename_prefix, video_path)
# Reuse your existing text overlay logic on each frame
overlay = TextOverlay()
reader = imageio.get_reader(video_path)
meta = reader.get_meta_data()
fps = meta.get("fps", 30)
# Try to get a sane total frame count
nframes_meta = meta.get("nframes", None)
duration = meta.get("duration", None) # seconds, if available
# Convert pause_seconds_before_start (seconds) to frames
try:
pause_frames = max(0, int(round(float(pause_seconds_before_start) * float(fps))))
except Exception:
pause_frames = max(0, int(pause_seconds_before_start))
total_frames = None
# 1) Trust nframes only if it's a reasonable integer
if isinstance(nframes_meta, (int, float)) and 0 < nframes_meta < 1e8:
total_frames = int(nframes_meta)
# 2) Otherwise, estimate from duration * fps if we have that
elif isinstance(duration, (int, float)) and duration > 0 and fps > 0:
total_frames = int(duration * fps)
# ComfyUI progress bar
comfy_pbar = None
if ProgressBar is not None and isinstance(total_frames, int) and total_frames > 0:
comfy_pbar = ProgressBar(total_frames)
# tqdm progress bar
if isinstance(total_frames, int) and total_frames > 0:
frame_iter = tqdm(reader, total=total_frames, desc="TextOverlayVideo")
else:
frame_iter = tqdm(reader, desc="TextOverlayVideo")
T = max(1, int(animation_frames)) if animate else 1
writer = imageio.get_writer(
out_path,
fps=fps,
macro_block_size=1 # avoid auto-resizing to multiples of 16
)
try:
for i, frame in enumerate(frame_iter):
pil_img = Image.fromarray(frame)
if i < pause_frames:
# Before pause: pass the frame through with no overlay
out_img = pil_img
else:
if animate:
# Animation timing: animate on frames [pause_frames .. pause_frames+T-1],
# then hold last pose afterwards
eff_idx = i - pause_frames
eff_t = min(eff_idx, T - 1)
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)
else:
op = 1.0
dx = dy = 0
use_cache = (i > pause_frames)
out_img = overlay.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,
)
writer.append_data(np.array(out_img))
# Update ComfyUI progress
if comfy_pbar is not None:
comfy_pbar.update(1)
finally:
writer.close()
reader.close()
# ---- NEW: mux original audio into the processed video using ffmpeg ----
try:
# We create a temporary output file, then replace the original out_path
tmp_out = out_path + ".tmp_audio.mp4"
# ffmpeg command:
# - input 0: processed video (no audio)
# - input 1: original video (with audio)
# - copy streams without re-encoding: -c copy
# - map video from 0, audio from 1
cmd = [
"ffmpeg",
"-y", # overwrite without asking
"-i", out_path,
"-i", video_path,
"-c", "copy",
"-map", "0:v:0",
"-map", "1:a:0",
tmp_out,
]
completed = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if completed.returncode == 0:
os.replace(tmp_out, out_path)
else:
# If something goes wrong, keep the silent video and print the error
print("[TextOverlayVideo] ffmpeg failed to mux audio, keeping silent video.")
print(completed.stderr.decode("utf-8", errors="ignore"))
except Exception as e:
# Fail gracefully: overlay still works, just no audio
print(f"[TextOverlayVideo] Could not mux audio from original video: {e}")
# NEW: optionally delete the original input video after processing is finished
if delete_original:
try:
os.remove(video_path)
except Exception as e:
print(f"[TextOverlayVideo] Failed to delete original video '{video_path}': {e}")
return (out_path,)
NODE_CLASS_MAPPINGS = {
"Advanced Text Overlay": TextOverlay,
"Advanced Text Overlay - Video": TextOverlayVideo,
}
+1 -1
View File
@@ -2,7 +2,7 @@
[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.2.0" # Semantic versioning
version = "1.4.0" # Semantic versioning
dependencies = [] # Populated from requirements.txt if present.
[project.urls]
+3 -1
View File
@@ -1,3 +1,5 @@
numpy
torch
Pillow
Pillow
imageio
tqdm