Files
Raykosan-ComfyUI_RaykoStudio/Rayko_Text_Overlay_Pro.py
T

1284 lines
50 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Copyright 2025-2026 Raykosan (RaykoStudio)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from scipy import ndimage
import os
import torch
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import functools
from typing import Dict, Tuple, List, Optional, Any
from dataclasses import dataclass
import time
import re
from collections import OrderedDict
import folder_paths
from server import PromptServer
from aiohttp import web
import freetype
import cairo
import cv2
import json
import asyncio
class LRUCache:
def __init__(self, max_size: int = 100):
self.cache = OrderedDict()
self.max_size = max_size
def get(self, key):
if key not in self.cache:
return None
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.max_size:
self.cache.popitem(last=False)
def clear(self):
self.cache.clear()
TRANSFORM_CACHE = LRUCache(max_size=50)
PENDING_TRANSFORMS = LRUCache(max_size=50)
RENDER_CACHE = LRUCache(max_size=100)
GLOBAL_RENDERER = None
def get_renderer():
global GLOBAL_RENDERER
if GLOBAL_RENDERER is None:
GLOBAL_RENDERER = RS_OverlayPro()
return GLOBAL_RENDERER
def safe_font_path(font_name: str) -> Optional[str]:
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(current_dir, "fonts")
if os.path.basename(font_name) != font_name:
return None
path = os.path.join(fonts_dir, font_name)
if os.path.isfile(path):
return path
return None
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro")
async def transform_handler(request):
try:
data = await request.json()
node_id = str(data.get("id"))
transforms = data.get("transforms")
text_params = data.get("text_params", {})
if node_id is None or transforms is None:
return web.json_response({"error": "Missing data"}, status=400)
required = {"x", "y", "rotation", "base_width_px", "base_height_px"}
if not all(k in transforms for k in required):
return web.json_response({"error": "Invalid transform data"}, status=400)
if not isinstance(text_params, dict):
return web.json_response({"error": "text_params must be object"}, status=400)
TRANSFORM_CACHE.put(node_id, {
**transforms,
"text_params": text_params
})
if node_id in PENDING_TRANSFORMS.cache:
PENDING_TRANSFORMS.cache[node_id]["status"] = "completed"
return web.json_response({"status": "ok"})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/rayko/rs_overlay_pro/get_fonts")
async def get_fonts_handler(request):
try:
renderer = get_renderer()
font_list = renderer._get_font_list()
return web.json_response({"font_list": font_list, "default_font": font_list[0] if font_list else ""})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cancel")
async def cancel_handler(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
if node_id:
PENDING_TRANSFORMS.cache[node_id] = {"status": "cancelled"}
return web.json_response({"status": "cancelled"})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cleanup")
async def cleanup_handler(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
TRANSFORM_CACHE.cache.pop(node_id, None)
PENDING_TRANSFORMS.cache.pop(node_id, None)
temp_dir = folder_paths.get_temp_directory()
for f in os.listdir(temp_dir):
if f.startswith(f"rs_overlay_pro_{node_id}_"):
try:
os.remove(os.path.join(temp_dir, f))
except:
pass
return web.json_response({"status": "ok"})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_masks")
async def render_masks_handler(request):
try:
data = await request.json()
text_params = data.get("text_params", {})
container_width = int(data.get("container_width", 400))
container_height = int(data.get("container_height", 200))
node_id = str(data.get("node_id", "unknown"))
if container_width <= 0 or container_height <= 0:
return web.json_response({"error": "Invalid container size"}, status=400)
renderer = get_renderer()
masks, render_size = renderer.render_masks_from_params(text_params, (container_width, container_height))
ts = int(time.time() * 1000)
result = {
"width": render_size[0],
"height": render_size[1],
"timestamp": ts
}
for mask_name, mask_img in masks.items():
filename = f"rs_overlay_pro_{node_id}_{ts}_{mask_name}.png"
filepath = os.path.join(folder_paths.get_temp_directory(), filename)
mask_img.save(filepath)
result[f"{mask_name}_file"] = filename
return web.json_response(result)
except Exception as e:
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_text")
async def render_text_handler(request):
try:
data = await request.json()
text_params = data.get("text_params", {})
container_width = int(data.get("container_width", 400))
container_height = int(data.get("container_height", 200))
node_id = str(data.get("node_id", "unknown"))
if container_width <= 0 or container_height <= 0:
return web.json_response({"error": "Invalid container size"}, status=400)
renderer = get_renderer()
text_layer = renderer.render_text_from_params(text_params, (container_width, container_height))
ts = int(time.time() * 1000)
filename = f"rs_overlay_pro_{node_id}_{ts}_text.png"
filepath = os.path.join(folder_paths.get_temp_directory(), filename)
text_layer.save(filepath)
return web.json_response({
"filename": filename,
"width": text_layer.width,
"height": text_layer.height,
"timestamp": ts
})
except Exception as e:
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_glow_mask")
async def render_glow_mask_handler(request):
try:
data = await request.json()
text_mask_file = data.get("text_mask_file")
glow_size = int(data.get("glow_size", 0))
glow_spread = int(data.get("glow_spread", 0))
glow_opacity = float(data.get("glow_opacity", 1.0))
node_id = str(data.get("node_id", "unknown"))
if not text_mask_file:
return web.json_response({"error": "Missing text_mask_file"}, status=400)
temp_dir = folder_paths.get_temp_directory()
mask_path = os.path.join(temp_dir, text_mask_file)
if not os.path.isfile(mask_path):
return web.json_response({"error": "text_mask_file not found"}, status=404)
cache_key = f"{text_mask_file}_{glow_size}_{glow_spread}_{glow_opacity}"
cached = RENDER_CACHE.get(cache_key)
if cached:
return web.json_response(cached)
text_mask = Image.open(mask_path).convert("RGBA")
w, h = text_mask.size
extra_padding = 0
if glow_size > 0:
extra_padding = int(glow_size * 1.0 + glow_spread * 0.3) + 10
extra_padding = min(extra_padding, 300)
expanded_w = w + 2 * extra_padding
expanded_h = h + 2 * extra_padding
expanded_mask = Image.new("RGBA", (expanded_w, expanded_h), (0, 0, 0, 0))
paste_x = extra_padding
paste_y = extra_padding
expanded_mask.paste(text_mask, (paste_x, paste_y), text_mask)
renderer = get_renderer()
glow_mask = renderer._apply_glow_to_mask(
expanded_mask, glow_size, glow_spread, glow_opacity
)
ts = int(time.time() * 1000)
filename = f"rs_overlay_pro_{node_id}_{ts}_glow.png"
filepath = os.path.join(temp_dir, filename)
glow_mask.save(filepath)
response_data = {
"glow_file": filename,
"width": glow_mask.width,
"height": glow_mask.height,
"timestamp": ts,
"extra_padding": extra_padding
}
RENDER_CACHE.put(cache_key, response_data)
return web.json_response(response_data)
except Exception as e:
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_shadow_mask")
async def render_shadow_mask(request):
try:
data = await request.json()
text_mask_file = data.get("text_mask_file")
shadow_blur = int(data.get("shadow_blur", 0))
node_id = data.get("node_id", "0")
if not text_mask_file:
return web.json_response({"error": "text_mask_file is required"}, status=400)
temp_dir = folder_paths.get_temp_directory()
text_mask_path = os.path.join(temp_dir, text_mask_file)
if not os.path.exists(text_mask_path):
print(f"[RS Overlay Pro] ERROR: file does not exist at {text_mask_path}")
return web.json_response({"error": "text_mask_file does not exist"}, status=404)
img = Image.open(text_mask_path).convert("L")
if shadow_blur > 0:
from PIL import ImageFilter
img = img.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
rgba = Image.new("RGBA", img.size, (0, 0, 0, 0))
rgba.putalpha(img)
timestamp = int(time.time() * 1000)
out_name = f"shadow_{node_id}_{timestamp}.png"
out_path = os.path.join(temp_dir, out_name)
rgba.save(out_path, optimize=True)
return web.json_response({
"shadow_file": out_name,
"timestamp": timestamp
})
except Exception as e:
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
@dataclass
class TextRenderConfig:
font: ImageFont.ImageFont
font_path: str
font_size: int
text_color: Tuple[int, int, int, int]
outline_color: Tuple[int, int, int, int]
outline_thickness: int
text_opacity: int = 100
outline_opacity: int = 100
effect_mode: str = "on"
text_scale_x: float = 1.0
text_scale_y: float = 1.0
text_align: str = "left"
line_spacing: float = 1.0
letter_spacing: float = 0.0
enable_glow: bool = False
glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255)
glow_size: int = 100
glow_spread: int = 150
glow_opacity: float = 1.0
enable_shadow: bool = False
shadow_color: Tuple[int, int, int, int] = (51, 51, 51, 255)
shadow_offset_x: int = 10
shadow_offset_y: int = 10
shadow_opacity: float = 0.8
shadow_blur: int = 0
@property
def has_outline(self) -> bool:
return self.effect_mode == "on" and self.outline_thickness > 0
class HybridTextRenderer:
@staticmethod
def render_text_mask_pillow(font_path: str, font_size: int, text: str,
container_size: Tuple[int, int], text_align: str = "left",
padding_x: int = 20, padding_y: int = 25,
line_spacing: float = 1.0,
letter_spacing: float = 0.0) -> Image.Image:
width, height = container_size
text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(text_layer)
try:
font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC)
except:
font = ImageFont.load_default()
lines = text.split('\n')
if not lines:
return text_layer
first_bbox = draw.textbbox((0, 0), lines[0], font=font)
line_height = first_bbox[3] - first_bbox[1]
if line_height == 0:
line_height = font_size
total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1)
available_height = height - 2 * padding_y
y_start = padding_y + (available_height - total_text_height) / 2
baseline_offset = -first_bbox[1]
y_start += baseline_offset
current_y = y_start
for line in lines:
if not line:
current_y += line_height * line_spacing
continue
line_bbox = draw.textbbox((0, 0), line, font=font)
line_width = line_bbox[2] - line_bbox[0]
if letter_spacing != 0.0 and len(line) > 1:
line_width += letter_spacing * (len(line) - 1)
available_width = width - 2 * padding_x
if text_align == "left":
x = padding_x - line_bbox[0]
elif text_align == "center":
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
elif text_align == "right":
x = width - padding_x - line_width - line_bbox[0]
else:
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
if letter_spacing != 0.0 and len(line) > 0:
pen_x = x
for char in line:
draw.text((pen_x, current_y), char, font=font, fill=(255, 255, 255, 255))
char_width = draw.textlength(char, font=font)
pen_x += char_width + letter_spacing
else:
draw.text((x, current_y), line, font=font, fill=(255, 255, 255, 255))
current_y += line_height * line_spacing
return text_layer
@staticmethod
def render_outline_mask_pillow(font_path: str, font_size: int, text: str,
outline_thickness: int, container_size: Tuple[int, int],
text_align: str = "left",
padding_x: int = 20, padding_y: int = 25,
line_spacing: float = 1.0,
letter_spacing: float = 0.0) -> Image.Image:
if outline_thickness <= 0:
return Image.new("RGBA", container_size, (0, 0, 0, 0))
width, height = container_size
text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(text_layer)
try:
font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC)
except:
font = ImageFont.load_default()
lines = text.split('\n')
if not lines:
return text_layer
first_bbox = draw.textbbox((0, 0), lines[0], font=font)
line_height = first_bbox[3] - first_bbox[1]
if line_height == 0:
line_height = font_size
total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1)
available_height = height - 2 * padding_y
y_start = padding_y + (available_height - total_text_height) / 2
baseline_offset = -first_bbox[1]
y_start += baseline_offset
current_y = y_start
for line in lines:
if not line:
current_y += line_height * line_spacing
continue
line_bbox = draw.textbbox((0, 0), line, font=font)
line_width = line_bbox[2] - line_bbox[0]
if letter_spacing != 0.0 and len(line) > 1:
line_width += letter_spacing * (len(line) - 1)
available_width = width - 2 * padding_x
if text_align == "left":
x = padding_x - line_bbox[0]
elif text_align == "center":
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
elif text_align == "right":
x = width - padding_x - line_width - line_bbox[0]
else:
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
if letter_spacing != 0.0 and len(line) > 0:
pen_x = x
for char in line:
draw.text((pen_x, current_y), char, font=font,
fill=(255, 255, 255, 255),
stroke_width=outline_thickness,
stroke_fill=(255, 255, 255, 255))
char_width = draw.textlength(char, font=font)
pen_x += char_width + letter_spacing
else:
draw.text((x, current_y), line, font=font,
fill=(255, 255, 255, 255),
stroke_width=outline_thickness,
stroke_fill=(255, 255, 255, 255))
current_y += line_height * line_spacing
return text_layer
@staticmethod
def apply_cairo_transform(image: Image.Image, scale_x: float, scale_y: float) -> Image.Image:
if abs(scale_x - 1.0) < 0.01 and abs(scale_y - 1.0) < 0.01:
return image
width, height = image.size
result_width = max(1, int(width * abs(scale_x)))
result_height = max(1, int(height * abs(scale_y)))
arr = np.array(image)
bgra = np.zeros_like(arr)
bgra[:, :, 0] = arr[:, :, 2]
bgra[:, :, 1] = arr[:, :, 1]
bgra[:, :, 2] = arr[:, :, 0]
bgra[:, :, 3] = arr[:, :, 3]
data = bgra.tobytes()
source_surface = cairo.ImageSurface.create_for_data(
bytearray(data), cairo.FORMAT_ARGB32, width, height
)
result_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, result_width, result_height)
ctx = cairo.Context(result_surface)
ctx.set_source_rgba(0, 0, 0, 0)
ctx.paint()
ctx.save()
ctx.translate(result_width / 2, result_height / 2)
ctx.scale(scale_x, scale_y)
ctx.translate(-width / 2, -height / 2)
ctx.set_source_surface(source_surface, 0, 0)
ctx.paint()
ctx.restore()
buf = result_surface.get_data()
arr = np.frombuffer(buf, dtype=np.uint8).reshape(result_height, result_width, 4)
rgba = np.zeros_like(arr)
rgba[:, :, 0] = arr[:, :, 2]
rgba[:, :, 1] = arr[:, :, 1]
rgba[:, :, 2] = arr[:, :, 0]
rgba[:, :, 3] = arr[:, :, 3]
return Image.fromarray(rgba, 'RGBA')
class RS_OverlayPro:
WEB_DIRECTORY = "web"
_font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
_freetype_cache: Dict[Tuple[str, int], freetype.Face] = {}
_text_dim_cache: LRUCache = LRUCache(max_size=200)
_hex_color_pattern = re.compile(r'^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$')
_hex_short_pattern = re.compile(r'^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$')
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
},
"hidden": {
"unique_id": "UNIQUE_ID"
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "composite"
CATEGORY = "🦊 RaykoStudio"
OUTPUT_NODE = True
DESCRIPTION = "Interactive node for overlaying text on images with real-time positioning, scaling, rotation, and text editing"
def __init__(self):
self.hybrid_renderer = HybridTextRenderer()
def get_font_path(self, font_name: str) -> Optional[str]:
return safe_font_path(font_name)
@functools.lru_cache(maxsize=64)
def get_cached_font(self, font_path: str, size: int) -> ImageFont.ImageFont:
cache_key = (font_path, size)
if cache_key in self._font_cache:
return self._font_cache[cache_key]
try:
font = ImageFont.truetype(font_path, size, layout_engine=ImageFont.Layout.BASIC)
font.path = font_path
font.size = size
self._font_cache[cache_key] = font
return font
except Exception as e:
raise Exception(f"Font error: {e}")
@functools.lru_cache(maxsize=64)
def get_freetype_face(self, font_path: str, size: int) -> freetype.Face:
cache_key = (font_path, size)
if cache_key in self._freetype_cache:
return self._freetype_cache[cache_key]
try:
face = freetype.Face(font_path)
face.set_char_size(size * 64)
self._freetype_cache[cache_key] = face
return face
except Exception as e:
raise Exception(f"FreeType error: {e}")
def parse_color(self, color: Any, alpha: float = 1.0) -> Tuple[int, int, int, int]:
if color is None:
return (255, 255, 255, int(alpha * 255))
try:
if isinstance(color, str):
color_str = color.strip()
match = self._hex_color_pattern.match(color_str)
if match:
r, g, b = int(match.group(1), 16), int(match.group(2), 16), int(match.group(3), 16)
a = int(match.group(4), 16) if match.group(4) else int(alpha * 255)
return (r, g, b, a)
else:
match_short = self._hex_short_pattern.match(color_str)
if match_short:
r, g, b = int(match_short.group(1)*2, 16), int(match_short.group(2)*2, 16), int(match_short.group(3)*2, 16)
a = int(alpha * 255)
return (r, g, b, a)
else:
return (255, 255, 255, int(alpha * 255))
else:
return (255, 255, 255, int(alpha * 255))
except Exception:
return (255, 255, 255, int(alpha * 255))
def _extract_text_params(self, text_params: dict, container_size: Tuple[int, int]) -> dict:
font_list = self._get_font_list()
text = text_params.get('text', '')
if not text or not text.strip():
text = ''
font_name = text_params.get('font_name', '')
if not font_name or font_name not in font_list:
font_name = font_list[0] if font_list else ''
font_path = self.get_font_path(font_name)
if font_path is None and font_list:
font_path = self.get_font_path(font_list[0])
text_color = text_params.get('text_color', '#FFFFFF')
outline_color = text_params.get('outline_color', '#808080')
outline_thickness = int(text_params.get('outline_thickness', 0))
effect_mode = str(text_params.get('effect_mode', 'on')).lower()
effect_mode = 'on' if effect_mode in ('on', 'off') else 'on'
text_scale_x = float(text_params.get('text_scale_x', 1.0))
text_scale_y = float(text_params.get('text_scale_y', 1.0))
text_align = str(text_params.get('text_align', 'left')).lower()
text_align = text_align if text_align in ('left', 'center', 'right') else 'left'
line_spacing = max(0.5, min(3.0, float(text_params.get('line_spacing', 1.0))))
letter_spacing = max(-20.0, min(100.0, float(text_params.get('letter_spacing', 0.0))))
min_font_size = int(text_params.get('min_font_size', 4))
max_font_size = int(text_params.get('max_font_size', 2000))
text_opacity = min(1.0, max(0.0, float(text_params.get('text_opacity', 1.0))))
outline_opacity = min(1.0, max(0.0, float(text_params.get('outline_opacity', 1.0))))
enable_glow = bool(text_params.get('enable_glow', False))
glow_color = text_params.get('glow_color', '#FFFFFF')
glow_size = int(text_params.get('glow_size', 0))
glow_spread = int(text_params.get('glow_spread', 0))
glow_opacity = min(1.0, max(0.0, float(text_params.get('glow_opacity', 1.0))))
padding_x = int(container_size[0] * 0.2) # 20% ширины
padding_y = int(container_size[1] * 0.15) # 15% высоты
return {
'text': text,
'font_name': font_name,
'font_path': font_path,
'text_color': text_color,
'outline_color': outline_color,
'outline_thickness': outline_thickness,
'effect_mode': effect_mode,
'text_scale_x': text_scale_x,
'text_scale_y': text_scale_y,
'text_align': text_align,
'line_spacing': line_spacing,
'letter_spacing': letter_spacing,
'min_font_size': min_font_size,
'max_font_size': max_font_size,
'text_opacity': text_opacity,
'outline_opacity': outline_opacity,
'enable_glow': enable_glow,
'glow_color': glow_color,
'glow_size': glow_size,
'glow_spread': glow_spread,
'glow_opacity': glow_opacity,
'padding_x': padding_x,
'padding_y': padding_y,
}
def calculate_text_dimensions_freetype(self, font_path: str, size: int, text: str,
outline_thickness: int, line_spacing: float = 1.0,
letter_spacing: float = 0.0) -> Tuple[float, float, List[float], List[float]]:
cache_key = (f"{font_path}:{size}", text, outline_thickness, letter_spacing, line_spacing)
cached = self._text_dim_cache.get(cache_key)
if cached is not None:
return cached
try:
face = self.get_freetype_face(font_path, size)
except:
return self.calculate_text_dimensions_legacy(f"{font_path}:{size}", text, outline_thickness, line_spacing, letter_spacing)
ascender = face.size.ascender / 64
descender = face.size.descender / 64
line_height_fixed = ascender - descender
lines = text.split('\n')
line_heights = []
line_widths = []
for line in lines:
line = line.replace('\t', ' ')
if not line:
line_heights.append(line_height_fixed + outline_thickness * 2)
line_widths.append(0 + outline_thickness * 2)
continue
ink_left = float('inf')
ink_right = float('-inf')
pen_x = 0
prev_glyph_index = 0
for i, char in enumerate(line):
try:
face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP)
glyph = face.glyph
metrics = glyph.metrics
if i > 0 and prev_glyph_index != 0:
kerning = face.get_kerning(prev_glyph_index, glyph.index)
pen_x += kerning.x / 64
glyph_left = pen_x + metrics.horiBearingX / 64
glyph_right = glyph_left + metrics.width / 64
ink_left = min(ink_left, glyph_left)
ink_right = max(ink_right, glyph_right)
pen_x += metrics.horiAdvance / 64
if letter_spacing != 0.0:
pen_x += letter_spacing
prev_glyph_index = glyph.index
except Exception:
continue
if ink_left == float('inf'):
line_width = 0
else:
line_width = ink_right - ink_left
line_heights.append(line_height_fixed + outline_thickness * 2)
line_widths.append(line_width + outline_thickness * 2)
if line_heights:
total_height = sum(line_heights)
if len(line_heights) > 1:
avg_line_height = line_heights[0]
total_height += (len(line_heights) - 1) * avg_line_height * (line_spacing - 1)
else:
total_height = 0
max_width = max(line_widths) if line_widths else 0
result = (total_height, max_width, line_heights, line_widths)
self._text_dim_cache.put(cache_key, result)
return result
def calculate_text_dimensions_legacy(self, font_key: str, text: str, outline_thickness: int,
line_spacing: float = 1.0, letter_spacing: float = 0.0) -> Tuple[float, float, list, list]:
temp_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(temp_img)
font_path, size_str = font_key.rsplit(':', 1) if ':' in font_key else (font_key, '12')
try:
font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default()
except:
font = ImageFont.load_default()
lines = text.split('\n')
line_heights, line_widths = [], []
for line in lines:
line = line.replace('\t', ' ')
if not line:
line_heights.append(0)
line_widths.append(0)
continue
bbox = draw.textbbox((0, 0), line, font=font)
line_width = draw.textlength(line, font=font)
if letter_spacing != 0.0 and len(line) > 1:
line_width += letter_spacing * (len(line) - 1)
line_height = (bbox[3] - bbox[1])
line_heights.append(line_height + outline_thickness * 2)
line_widths.append(line_width + outline_thickness * 2)
if line_heights:
total_height = sum(line_heights)
if len(line_heights) > 1:
avg_h = line_heights[0]
total_height += (len(line_heights) - 1) * avg_h * (line_spacing - 1)
else:
total_height = 0
max_width = max(line_widths) if line_widths else 0
return (total_height, max_width, line_heights, line_widths)
def text_fits(self, font_path: str, size: int, text: str, max_width: float, max_height: float,
outline_thickness: int, line_spacing: float = 1.0, letter_spacing: float = 0.0) -> bool:
total_height, max_line_width, _, _ = self.calculate_text_dimensions_freetype(
font_path, size, text, outline_thickness, line_spacing, letter_spacing
)
return max_line_width <= max_width and total_height <= max_height
def find_optimal_font_size(self, font_path: Optional[str], text: str,
max_width: float, max_height: float,
min_size: int, max_size: int, config: TextRenderConfig) -> Tuple[ImageFont.ImageFont, int]:
if font_path is None:
font_path = "default"
if self.text_fits(font_path, max_size, text, max_width, max_height,
config.outline_thickness,
config.line_spacing, config.letter_spacing):
try:
font = self.get_cached_font(font_path, max_size) if font_path != "default" else ImageFont.load_default()
if font_path != "default" and hasattr(font, 'path'):
font.path = font_path
font.size = max_size
return font, max_size
except:
pass
low, high = min_size, max_size
best_size, best_font = min_size, None
iterations = 0
max_iterations = 50
while low <= high and iterations < max_iterations:
mid = (low + high) // 2
iterations += 1
try:
font = self.get_cached_font(font_path, mid) if font_path != "default" else ImageFont.load_default()
except:
high = mid - 1
continue
if self.text_fits(font_path, mid, text, max_width, max_height,
config.outline_thickness,
config.line_spacing, config.letter_spacing):
best_font, best_size = font, mid
low = mid + 1
else:
high = mid - 1
if best_font is None:
try:
best_font = self.get_cached_font(font_path, min_size) if font_path != "default" else ImageFont.load_default()
if font_path != "default" and hasattr(best_font, 'path'):
best_font.path = font_path
best_font.size = min_size
except:
best_font = ImageFont.load_default()
return best_font, best_size
def _get_font_list(self) -> List[str]:
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(current_dir, "fonts")
font_list = []
if os.path.isdir(fonts_dir):
for f in sorted(os.listdir(fonts_dir)):
if f.lower().endswith(('.ttf', '.otf', '.ttc')):
font_list.append(f)
font_list = [f for f in font_list if os.path.basename(f) == f]
if "Arial.ttf" in font_list:
font_list.remove("Arial.ttf")
font_list.insert(0, "Arial.ttf")
if not font_list:
font_list = ["Arial.ttf"]
return font_list
def render_masks_from_params(self, text_params: dict, container_size: Tuple[int, int]) -> Tuple[Dict[str, Image.Image], Tuple[int, int]]:
p = self._extract_text_params(text_params, container_size)
text = p['text']
if not text:
empty_mask = Image.new("RGBA", container_size, (0,0,0,0))
return {'text_mask': empty_mask, 'outline_mask': empty_mask}, container_size
font_path = p['font_path']
if font_path is None:
font_list = self._get_font_list()
font_path = self.get_font_path(font_list[0])
temp_config = TextRenderConfig(
font=None,
font_path=font_path,
font_size=0,
text_color=(255,255,255,255),
outline_color=(255,255,255,255),
outline_thickness=p['outline_thickness'],
effect_mode=p['effect_mode'],
text_scale_x=p['text_scale_x'],
text_scale_y=p['text_scale_y'],
text_align=p['text_align'],
line_spacing=p['line_spacing'],
letter_spacing=p['letter_spacing']
)
available_w = max(1, container_size[0] - 2 * p['padding_x'])
available_h = max(1, container_size[1] - 2 * p['padding_y'])
font, actual_size = self.find_optimal_font_size(
font_path, text, available_w, available_h,
p['min_font_size'], p['max_font_size'], temp_config
)
text_mask = self.hybrid_renderer.render_text_mask_pillow(
font_path, actual_size, text, container_size, p['text_align'],
p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing']
)
text_mask = self.hybrid_renderer.apply_cairo_transform(
text_mask, p['text_scale_x'], p['text_scale_y']
)
masks = {'text_mask': text_mask}
if p['effect_mode'] == "on" and p['outline_thickness'] > 0:
outline_mask = self.hybrid_renderer.render_outline_mask_pillow(
font_path, actual_size, text, p['outline_thickness'], container_size,
p['text_align'], p['padding_x'], p['padding_y'],
p['line_spacing'], p['letter_spacing']
)
text_mask_no_transform = self.hybrid_renderer.render_text_mask_pillow(
font_path, actual_size, text, container_size, p['text_align'],
p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing']
)
text_arr = np.array(text_mask_no_transform)
outline_arr = np.array(outline_mask)
text_alpha = text_arr[:, :, 3].astype(np.float32)
outline_alpha = outline_arr[:, :, 3].astype(np.float32)
outline_only_alpha = np.clip(outline_alpha - text_alpha, 0, 255).astype(np.uint8)
outline_only_arr = np.zeros_like(outline_arr)
outline_only_arr[:, :, 0] = 255
outline_only_arr[:, :, 1] = 255
outline_only_arr[:, :, 2] = 255
outline_only_arr[:, :, 3] = outline_only_alpha
outline_only_mask = Image.fromarray(outline_only_arr, 'RGBA')
outline_only_mask = self.hybrid_renderer.apply_cairo_transform(
outline_only_mask, p['text_scale_x'], p['text_scale_y']
)
masks['outline_mask'] = outline_only_mask
else:
masks['outline_mask'] = Image.new("RGBA", (text_mask.width, text_mask.height), (0,0,0,0))
render_size = (text_mask.width, text_mask.height)
return masks, render_size
def render_text_from_params(self, text_params: dict, container_size: Tuple[int, int], include_glow: bool = False) -> Image.Image:
p = self._extract_text_params(text_params, container_size)
text = p['text']
if not text:
return Image.new("RGBA", container_size, (0,0,0,0))
masks, render_size = self.render_masks_from_params(text_params, container_size)
text_mask = masks['text_mask']
outline_mask = masks['outline_mask']
# Извлекаем shadow-параметры
enable_shadow = bool(text_params.get('enable_shadow', False))
shadow_color = text_params.get('shadow_color', '#333333')
shadow_opacity = min(1.0, max(0.0, float(text_params.get('shadow_opacity', 0.8))))
shadow_offset_x = int(text_params.get('shadow_offset_x', 0))
shadow_offset_y = int(text_params.get('shadow_offset_y', 0))
shadow_blur = int(text_params.get('shadow_blur', 0))
config = TextRenderConfig(
font=None,
font_path=p['font_path'],
font_size=0,
text_color=self.parse_color(p['text_color'], p['text_opacity']),
outline_color=self.parse_color(p['outline_color'], p['outline_opacity']),
outline_thickness=p['outline_thickness'],
effect_mode=p['effect_mode'],
text_scale_x=p['text_scale_x'],
text_scale_y=p['text_scale_y'],
text_align=p['text_align'],
line_spacing=p['line_spacing'],
letter_spacing=p['letter_spacing'],
enable_glow=p['enable_glow'],
glow_color=self.parse_color(p['glow_color'], p['glow_opacity']),
glow_size=p['glow_size'],
glow_spread=p['glow_spread'],
glow_opacity=p['glow_opacity'],
enable_shadow=enable_shadow,
shadow_color=self.parse_color(shadow_color, shadow_opacity),
shadow_offset_x=shadow_offset_x,
shadow_offset_y=shadow_offset_y,
shadow_opacity=shadow_opacity,
shadow_blur=shadow_blur
)
result = Image.new("RGBA", render_size, (0,0,0,0))
# 1. GLOW
if include_glow and config.enable_glow and config.glow_size > 0:
glow_mask = self._apply_glow_to_mask(
text_mask, config.glow_size, config.glow_spread, config.glow_opacity
)
glow_colored = self._apply_color_to_mask(glow_mask, config.glow_color)
result = Image.alpha_composite(result, glow_colored)
# 2. SHADOW (под текстом и outline)
if config.enable_shadow:
shadow_mask = self._apply_shadow_to_mask(
text_mask, config.shadow_blur, config.shadow_offset_x, config.shadow_offset_y
)
shadow_colored = self._apply_color_to_mask(shadow_mask, config.shadow_color)
result = Image.alpha_composite(result, shadow_colored)
# 3. OUTLINE
if config.has_outline:
outline_colored = self._apply_color_to_mask(outline_mask, config.outline_color)
result = Image.alpha_composite(result, outline_colored)
# 4. TEXT
text_colored = self._apply_color_to_mask(text_mask, config.text_color)
result = Image.alpha_composite(result, text_colored)
return result
def _apply_glow_to_mask(self, text_mask: Image.Image, glow_size: int,
glow_spread: int, glow_opacity: float) -> Image.Image:
if glow_size <= 0:
return Image.new("RGBA", text_mask.size, (0, 0, 0, 0))
w, h = text_mask.size
alpha = np.array(text_mask.getchannel("A"), dtype=np.uint8)
_, binary = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY)
original_mask = binary.copy()
if glow_spread > 0:
iterations = max(1, int(glow_spread / 15))
kernel = np.ones((3, 3), np.uint8)
binary = cv2.dilate(binary, kernel, iterations=iterations)
sigma = max(0.5, glow_size / 2.0)
blurred = cv2.GaussianBlur(binary, (0, 0), sigmaX=sigma, sigmaY=sigma)
glow_float = blurred.astype(np.float32) / 255.0
original_float = original_mask.astype(np.float32) / 255.0
glow_outer = glow_float * (1.0 - original_float)
glow_outer = glow_outer * glow_opacity
glow_arr = np.clip(glow_outer * 255, 0, 255).astype(np.uint8)
glow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
glow_rgba[:, :, 3] = glow_arr
return Image.fromarray(glow_rgba, 'RGBA')
def _apply_color_to_mask(self, mask: Image.Image, color: Tuple[int, int, int, int]) -> Image.Image:
arr = np.array(mask)
alpha = arr[:, :, 3]
colored_arr = np.zeros_like(arr)
colored_arr[:, :, 0] = color[0]
colored_arr[:, :, 1] = color[1]
colored_arr[:, :, 2] = color[2]
colored_arr[:, :, 3] = (alpha.astype(np.float32) * (color[3] / 255.0)).astype(np.uint8)
return Image.fromarray(colored_arr, 'RGBA')
def _apply_shadow_to_mask(self, text_mask: Image.Image, shadow_blur: int,
shadow_offset_x: int, shadow_offset_y: int) -> Image.Image:
from scipy import ndimage
w, h = text_mask.size
alpha = np.array(text_mask.getchannel("A"), dtype=np.float32)
if shadow_blur > 0:
alpha = ndimage.gaussian_filter(alpha, sigma=shadow_blur / 2.0)
shadow_alpha = np.zeros((h, w), dtype=np.float32)
if shadow_offset_x >= 0:
src_x_start, src_x_end = 0, w - shadow_offset_x
dst_x_start, dst_x_end = shadow_offset_x, w
else:
src_x_start, src_x_end = -shadow_offset_x, w
dst_x_start, dst_x_end = 0, w + shadow_offset_x
if shadow_offset_y >= 0:
src_y_start, src_y_end = 0, h - shadow_offset_y
dst_y_start, dst_y_end = shadow_offset_y, h
else:
src_y_start, src_y_end = -shadow_offset_y, h
dst_y_start, dst_y_end = 0, h + shadow_offset_y
src_x_start = max(0, src_x_start)
src_x_end = max(0, min(w, src_x_end))
src_y_start = max(0, src_y_start)
src_y_end = max(0, min(h, src_y_end))
dst_x_start = max(0, dst_x_start)
dst_x_end = max(0, min(w, dst_x_end))
dst_y_start = max(0, dst_y_start)
dst_y_end = max(0, min(h, dst_y_end))
copy_w = min(src_x_end - src_x_start, dst_x_end - dst_x_start)
copy_h = min(src_y_end - src_y_start, dst_y_end - dst_y_start)
if copy_w > 0 and copy_h > 0:
shadow_alpha[dst_y_start:dst_y_start+copy_h, dst_x_start:dst_x_start+copy_w] = \
alpha[src_y_start:src_y_start+copy_h, src_x_start:src_x_start+copy_w]
shadow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
shadow_rgba[:, :, 3] = np.clip(shadow_alpha, 0, 255).astype(np.uint8)
return Image.fromarray(shadow_rgba, 'RGBA')
def _calculate_extra_padding(self, text_params: dict) -> int:
extra = 0
if text_params.get('enable_glow', False):
glow_size = int(text_params.get('glow_size', 0))
glow_spread = int(text_params.get('glow_spread', 0))
if glow_size > 0:
extra = max(extra, int(glow_size * 1.0 + glow_spread * 0.3) + 10)
if text_params.get('enable_shadow', False):
shadow_offset_x = abs(int(text_params.get('shadow_offset_x', 0)))
shadow_offset_y = abs(int(text_params.get('shadow_offset_y', 0)))
shadow_extra = max(shadow_offset_x, shadow_offset_y) + 20
extra = max(extra, shadow_extra)
extra = min(extra, 300)
return extra
def tensor2pil(self, tensor):
if len(tensor.shape) == 4:
if tensor.shape[0] > 1:
tensor = tensor[0]
arr = (tensor.cpu().numpy() * 255).astype(np.uint8)
while arr.ndim > 3:
arr = arr[0]
if arr.ndim < 3:
arr = np.expand_dims(arr, axis=-1)
c = arr.shape[2]
if c >= 4:
return Image.fromarray(arr[:,:,:4], 'RGBA')
if c == 3:
return Image.fromarray(arr, 'RGB')
return Image.fromarray(arr[:,:,0], 'L')
def pil2tensor(self, pil):
if pil.mode == 'L':
pil = pil.convert('RGB')
elif pil.mode != 'RGB':
pass
return torch.from_numpy(np.array(pil).astype(np.float32) / 255.0).unsqueeze(0)
def composite(self, image, unique_id=None):
unique_id = str(unique_id) if unique_id is not None else "unknown"
if image.numel() == 0:
return (image,)
h, w = image.shape[1], image.shape[2]
if h < 8 or w < 8:
return (image,)
TRANSFORM_CACHE.cache.pop(unique_id, None)
bg_pil = self.tensor2pil(image)
bg_w, bg_h = bg_pil.size
font_list = self._get_font_list()
ts = int(time.time() * 1000)
td = folder_paths.get_temp_directory()
for f in os.listdir(td):
if f.startswith(f"rs_overlay_pro_{unique_id}_"):
try:
os.remove(os.path.join(td, f))
except:
pass
bfn = f"rs_overlay_pro_{unique_id}_{ts}_bg.png"
bg_pil.save(os.path.join(td, bfn))
PENDING_TRANSFORMS.cache[unique_id] = {"status": "pending"}
PromptServer.instance.send_sync("rs-overlay-pro-start", {
"id": unique_id,
"bg_file": bfn,
"bg_width": bg_w,
"bg_height": bg_h,
"timestamp": ts,
"font_list": font_list,
"default_font": font_list[0]
})
print(f"[RS Overlay Pro] Node {unique_id} waiting for user input (Apply/Cancel)...")
timeout = 300
start_time = time.time()
while unique_id not in TRANSFORM_CACHE.cache:
if unique_id in PENDING_TRANSFORMS.cache and PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled":
break
if time.time() - start_time > timeout:
print(f"[RS Overlay Pro] Timeout for node {unique_id}")
PENDING_TRANSFORMS.cache.pop(unique_id, None)
try:
os.remove(os.path.join(td, bfn))
except:
pass
return (self.pil2tensor(bg_pil),)
time.sleep(0.2)
if unique_id in PENDING_TRANSFORMS.cache:
if PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled":
print(f"[RS Overlay Pro] Cancelled by user for node {unique_id}")
PENDING_TRANSFORMS.cache.pop(unique_id, None)
try:
os.remove(os.path.join(td, bfn))
except:
pass
return (self.pil2tensor(bg_pil),)
print(f"[RS Overlay Pro] Input received for node {unique_id}")
PENDING_TRANSFORMS.cache.pop(unique_id, None)
data = TRANSFORM_CACHE.cache.pop(unique_id, None)
if data is None:
return (self.pil2tensor(bg_pil),)
text_params = data.get("text_params", {})
text = text_params.get('text', '')
if not text or not text.strip():
try:
os.remove(os.path.join(td, bfn))
except:
pass
return (self.pil2tensor(bg_pil),)
rx = bg_w / 2 if data.get("x") is None else float(data.get("x"))
ry = bg_h / 2 if data.get("y") is None else float(data.get("y"))
rot = float(data.get("rotation") or 0)
base_width_px = float(text_params.get('base_width_px', bg_w))
base_height_px = float(text_params.get('base_height_px', bg_w * 3 / 5))
base_width_px = max(10, min(bg_w, base_width_px))
base_height_px = max(10, min(bg_h, base_height_px))
text_layer = self.render_text_from_params(
text_params, (int(base_width_px), int(base_height_px)),
include_glow=True
)
if rot != 0:
text_layer = text_layer.rotate(-rot, expand=True, resample=Image.Resampling.BICUBIC,
center=(text_layer.width//2, text_layer.height//2))
result = bg_pil.copy().convert("RGBA")
paste_x = int(rx - text_layer.width // 2)
paste_y = int(ry - text_layer.height // 2)
result.paste(text_layer, (paste_x, paste_y), text_layer if text_layer.mode == 'RGBA' else None)
try:
os.remove(os.path.join(td, bfn))
except:
pass
return (self.pil2tensor(result.convert("RGB")),)
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()
NODE_CLASS_MAPPINGS = {"RS_OverlayPro": RS_OverlayPro}
NODE_DISPLAY_NAME_MAPPINGS = {"RS_OverlayPro": "🦊 RS Text Overlay Pro"}