From 63af207193cb57f41552b680be7288c5bc375bc6 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:34:29 +0300 Subject: [PATCH] Adding a new RS Text Overlay Pro node --- Example/RS Text Overlay Pro.json | 1 + Rayko_Text_Overlay.py | 864 ---------- Rayko_Text_Overlay_Pro.py | 1269 +++++++++++++++ __init__.py | 2 +- fonts/AlGhazalia Arab.otf | Bin 0 -> 156476 bytes fonts/Bbroken Detroit.otf | Bin 0 -> 899140 bytes fonts/Facon.otf | Bin 0 -> 72932 bytes fonts/Fatboy.ttf | Bin 0 -> 70440 bytes fonts/Foglihten.otf | Bin 0 -> 711088 bytes fonts/Keetano Katakana.ttf | Bin 0 -> 86448 bytes fonts/Legend.ttf | Bin 0 -> 106048 bytes fonts/Project Space.otf | Bin 0 -> 16056 bytes fonts/Roboto-Black.ttf | Bin 0 -> 164936 bytes fonts/Roboto-Thin.ttf | Bin 0 -> 122512 bytes fonts/RobotoSlab-Bold.ttf | Bin 0 -> 170616 bytes fonts/RobotoSlab-Regular.ttf | Bin 0 -> 169064 bytes fonts/Runic AltNo.ttf | Bin 0 -> 108280 bytes fonts/Runic.ttf | Bin 0 -> 162488 bytes fonts/Rusticus.otf | Bin 0 -> 39876 bytes fonts/Zenter.otf | Bin 0 -> 25948 bytes package.json | 4 +- pyproject.toml | 2 +- requirements.txt | 11 +- web/rs_overlay_pro.js | 2567 ++++++++++++++++++++++++++++++ web/rs_text_overlay.js | 424 ----- 25 files changed, 3848 insertions(+), 1296 deletions(-) create mode 100644 Example/RS Text Overlay Pro.json delete mode 100644 Rayko_Text_Overlay.py create mode 100644 Rayko_Text_Overlay_Pro.py create mode 100644 fonts/AlGhazalia Arab.otf create mode 100644 fonts/Bbroken Detroit.otf create mode 100644 fonts/Facon.otf create mode 100644 fonts/Fatboy.ttf create mode 100644 fonts/Foglihten.otf create mode 100644 fonts/Keetano Katakana.ttf create mode 100644 fonts/Legend.ttf create mode 100644 fonts/Project Space.otf create mode 100644 fonts/Roboto-Black.ttf create mode 100644 fonts/Roboto-Thin.ttf create mode 100644 fonts/RobotoSlab-Bold.ttf create mode 100644 fonts/RobotoSlab-Regular.ttf create mode 100644 fonts/Runic AltNo.ttf create mode 100644 fonts/Runic.ttf create mode 100644 fonts/Rusticus.otf create mode 100644 fonts/Zenter.otf create mode 100644 web/rs_overlay_pro.js delete mode 100644 web/rs_text_overlay.js diff --git a/Example/RS Text Overlay Pro.json b/Example/RS Text Overlay Pro.json new file mode 100644 index 0000000..1f1643d --- /dev/null +++ b/Example/RS Text Overlay Pro.json @@ -0,0 +1 @@ +{"id":"ec300d91-5775-45b4-952d-d1c8a58a5691","revision":0,"last_node_id":106,"last_link_id":183,"nodes":[{"id":100,"type":"RS_OverlayPro","pos":[2540,130],"size":[500,730],"flags":{"pinned":true},"order":4,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":169}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[182]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":3,"type":"PreviewImage","pos":[3060,130],"size":[570,730],"flags":{"pinned":true},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":182}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":[183]}],"properties":{"cnr_id":"comfy-core","ver":"0.25.0","Node name for S&R":"PreviewImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":105,"type":"SaveImage","pos":[3650,130],"size":[590,730],"flags":{"pinned":true},"order":6,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":183},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.26.0","Node name for S&R":"SaveImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI"]},{"id":103,"type":"RS_OverlayPro","pos":[1500,130],"size":[500,730],"flags":{"pinned":true},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":178}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[179]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":99,"type":"RS_OverlayPro","pos":[2020,130],"size":[500,730],"flags":{"pinned":true},"order":3,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":179}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[169]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":2,"type":"LoadImage","pos":[1070,130],"size":[410,570],"flags":{"pinned":true},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[178]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.25.0","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI_00005_ (2).png","image"]},{"id":106,"type":"Fast Groups Bypasser (rgthree)","pos":[1070,760],"size":[410,100],"flags":{"pinned":true},"order":1,"mode":0,"inputs":[],"outputs":[{"name":"OPT_CONNECTION","type":"*","links":null}],"properties":{"matchColors":"","matchTitle":"","showNav":true,"showAllGraphs":true,"sort":"position","customSortAlphabet":"","toggleRestriction":"default","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"color":"#322","bgcolor":"#533"}],"links":[[169,99,0,100,0,"IMAGE"],[178,2,0,103,0,"IMAGE"],[179,103,0,99,0,"IMAGE"],[182,100,0,3,0,"IMAGE"],[183,3,0,105,0,"IMAGE"]],"groups":[{"id":1,"title":"Overlay 2","bounding":[2010,60,520,810],"color":"#3f789e","flags":{"pinned":true}},{"id":2,"title":"Overlay 3","bounding":[2530,60,520,810],"color":"#3f789e","flags":{"pinned":true}}],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.6934334949441373,"offset":[-463.92423240571884,203.39108182291372]}},"version":0.4} \ No newline at end of file diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py deleted file mode 100644 index 2b19c3e..0000000 --- a/Rayko_Text_Overlay.py +++ /dev/null @@ -1,864 +0,0 @@ -# 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. - -import os -import torch -import numpy as np -from PIL import Image, ImageDraw, ImageFont, ImageFilter -import cv2 -import math -import functools -from typing import Dict, Tuple, List, Optional, Any -from dataclasses import dataclass -import json -import time -import re -import unicodedata -from contextlib import contextmanager -from collections import OrderedDict - -try: - from comfy.utils import ProgressBar as ComfyProgressBar - HAS_PBAR = True -except ImportError: - HAS_PBAR = False - -def ComfyProgressBar(total: int, desc: str = ""): - class DummyPBar: - def update(self, n: int = 1): pass - def update_absolute(self, n: int): pass - return DummyPBar() - -try: - import folder_paths - HAS_FOLDER_PATHS = True -except ImportError: - HAS_FOLDER_PATHS = False - -class FontNotFoundError(Exception): pass - -@dataclass -class TextRenderConfig: - font: ImageFont.ImageFont - text_color: Tuple[int, int, int, int] - outline_color: Tuple[int, int, int, int] - outline_thickness: int - letter_spacing: float - line_spacing: float - enable_shadow: bool - shadow_color: Tuple[int, int, int, int] - shadow_offset: Tuple[int, int] - shadow_blur: float - shadow_opacity: float - enable_glow: bool = False - glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255) - glow_size: int = 150 - glow_spread: int = 250 - glow_brightness: float = 1.0 - text_opacity: int = 100 - use_supersampling: bool = False - use_edge_smoothing: bool = True - supersampling_factor: int = 2 - - @property - def has_outline(self) -> bool: - return self.outline_thickness > 0 - - @property - def has_shadow(self) -> bool: - return self.enable_shadow - - @property - def has_glow(self) -> bool: - return self.enable_glow - -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() - - def __len__(self): - return len(self.cache) - -class HighQualityTextRenderer: - @staticmethod - def render_with_supersampling(text: str, config: TextRenderConfig, - container_size: Tuple[int, int], - render_func, scale_factor: int = 2) -> Image.Image: - scaled_size = (container_size[0] * scale_factor, container_size[1] * scale_factor) - - temp_config = TextRenderConfig( - font=None, - text_color=config.text_color, - outline_color=config.outline_color, - outline_thickness=config.outline_thickness * scale_factor, - letter_spacing=config.letter_spacing * scale_factor, - line_spacing=config.line_spacing, - enable_shadow=config.enable_shadow, - shadow_color=config.shadow_color, - shadow_offset=(config.shadow_offset[0] * scale_factor, - config.shadow_offset[1] * scale_factor), - shadow_blur=config.shadow_blur * scale_factor, - shadow_opacity=config.shadow_opacity, - text_opacity=config.text_opacity, - use_supersampling=False, - use_edge_smoothing=False, - enable_glow=config.enable_glow, - glow_color=config.glow_color, - glow_size=config.glow_size * scale_factor, - glow_spread=config.glow_spread * scale_factor, - glow_brightness=config.glow_brightness - ) - - try: - scaled_font_size = int(config.font.size * scale_factor) - temp_config.font = ImageFont.truetype( - config.font.path if hasattr(config.font, 'path') else "default", - scaled_font_size - ) - except: - temp_config.font = config.font - - temp_layer = render_func(text, temp_config, scaled_size) - result = temp_layer.resize(container_size, Image.Resampling.LANCZOS) - return result - - @staticmethod - def apply_edge_smoothing(text_layer: Image.Image) -> Image.Image: - arr = np.array(text_layer) - - if arr.ndim == 3 and arr.shape[2] == 4: - alpha = arr[:, :, 3] - rgb = arr[:, :, :3] - edges = cv2.Canny(alpha, 50, 150) - - if np.any(edges): - smoothed_rgb = cv2.bilateralFilter(rgb, 5, 50, 50) - alpha_smoothed = cv2.GaussianBlur(alpha, (3, 3), 0.5) - edge_mask = edges > 0 - edge_mask_3d = np.stack([edge_mask] * 4, axis=2) - result_arr = np.dstack([smoothed_rgb, alpha_smoothed]) - original_arr = np.dstack([rgb, alpha]) - result = np.where(edge_mask_3d, result_arr, original_arr) - return Image.fromarray(result.astype(np.uint8), 'RGBA') - - return text_layer - -class RS_TextOverlay: - WEB_DIRECTORY = "web" - _font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {} - _text_dim_cache: Dict[Tuple[str, str, float, float, int], Tuple[float, float, List[float], List[float]]] = {} - _contour_cache: Dict[int, float] = {} - _render_cache: LRUCache = None - - _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): - 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) - - if not font_list: - font_list = ["Arial.ttf"] - - return { - "required": { - "image": ("IMAGE",), - "mask": ("MASK",), - "node_data": ("STRING", { - "default": json.dumps({ - "text": "Текст", - "font_name": font_list[0], - "text_color": "#FFFFFF", - "outline_thickness": 0, - "outline_color": "#808080", - "rotate_with_mask": True, - "text_opacity": 1.0, - "line_spacing": 1.0, - "letter_spacing": 0.0, - "enable_shadow": False, - "shadow_color": "#333333", - "shadow_offset_x": 10, - "shadow_offset_y": 10, - "shadow_blur": 0.2, - "shadow_opacity": 0.8, - "enable_glow": False, - "glow_color": "#FFFFFF", - "glow_size": 150, - "glow_spread": 250, - "glow_brightness": 1.0, - "font_list": font_list, - "use_supersampling": False, - "use_edge_smoothing": True, - "supersampling_factor": 2 - }), - "hidden": True - }), - }, - "optional": {} - } - - RETURN_TYPES = ("IMAGE",) - FUNCTION = "apply_text" - CATEGORY = "🦊 RaykoStudio" - DESCRIPTION = "Node allows you to overlay text on images using masks" - - def __init__(self): - self.debug = False - self.debug_log = [] - self.quality_renderer = HighQualityTextRenderer() - - if self._render_cache is None: - self.__class__._render_cache = LRUCache(max_size=100) - - self.performance_metrics = { - 'render_time': [], - 'font_find_time': [], - 'mask_process_time': [], - 'total_time': [] - } - - self._mask_cache = LRUCache(max_size=50) - self._instance_color_cache: Dict[str, Tuple[int, int, int, int]] = {} - - def log(self, message: str, level: int = 1): - if self.debug or level <= 0: - timestamp = time.strftime("%H:%M:%S") - prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•") - print(f"[{timestamp}] {prefix} RS_TextOverlay: {message}") - - if self.debug: - self.debug_log.append(f"[{timestamp}] {prefix} {message}") - if len(self.debug_log) > 1000: - self.debug_log = self.debug_log[-500:] - - @contextmanager - def measure_time(self, metric_name: str): - start = time.perf_counter() - yield - elapsed = time.perf_counter() - start - - if metric_name in self.performance_metrics: - self.performance_metrics[metric_name].append(elapsed) - if len(self.performance_metrics[metric_name]) > 100: - self.performance_metrics[metric_name] = self.performance_metrics[metric_name][-50:] - - def _validate_inputs(self, image: torch.Tensor, mask: torch.Tensor) -> bool: - if image is None or mask is None: - self.log("Image or mask is None", level=0) - return False - if image.numel() == 0 or mask.numel() == 0: - self.log("Empty tensor", level=0) - return False - if image.ndim not in [3, 4]: - self.log(f"Invalid image dimensions: {image.ndim}", level=0) - return False - if mask.ndim not in [2, 3, 4]: - self.log(f"Invalid mask dimensions: {mask.ndim}", level=0) - return False - if torch.isnan(image).any() or torch.isinf(image).any(): - self.log("Image contains NaN or Inf values", level=0) - return False - return True - - def _validate_text(self, text: str) -> str: - if not isinstance(text, str): - return "Текст" - if not text.strip(): - return "Текст" - text = unicodedata.normalize('NFKC', text) - text = ''.join(char for char in text if char.isprintable() or char == '\n') - max_length = 1000 - if len(text) > max_length: - text = text[:max_length] + "..." - self.log(f"Text truncated to {max_length} characters", level=1) - return text - - def _parse_and_validate_node_data(self, node_data: str) -> dict: - 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) - - if not font_list: - font_list = ["Arial.ttf"] - - defaults = { - 'text': 'Текст', - 'font_name': font_list[0], - 'text_color': '#FFFFFF', - 'outline_thickness': 0, - 'outline_color': '#808080', - 'rotate_with_mask': True, - 'text_opacity': 1.0, - 'line_spacing': 1.0, - 'letter_spacing': 0.0, - 'enable_shadow': False, - 'shadow_color': '#333333', - 'shadow_offset_x': 10, - 'shadow_offset_y': 10, - 'shadow_blur': 0.2, - 'shadow_opacity': 0.8, - 'enable_glow': False, - 'glow_color': '#FFFFFF', - 'glow_size': 150, - 'glow_spread': 250, - 'glow_brightness': 1.0, - 'font_list': font_list, - 'use_supersampling': False, - 'use_edge_smoothing': True, - 'supersampling_factor': 2, - 'min_font_size': 8, - 'max_font_size': 500, - 'padding': 5 - } - - if not node_data: - return defaults - - try: - data = json.loads(node_data) - - data['text'] = self._validate_text(data.get('text', defaults['text'])) - data['outline_thickness'] = max(0, min(50, data.get('outline_thickness', 0))) - data['text_opacity'] = max(0.0, min(1.0, data.get('text_opacity', 1.0))) - data['line_spacing'] = max(0.5, min(3.0, data.get('line_spacing', 1.0))) - data['letter_spacing'] = max(-20.0, min(100.0, data.get('letter_spacing', 0.0))) - data['shadow_blur'] = max(0.0, min(1.0, data.get('shadow_blur', 0.2))) - data['shadow_opacity'] = max(0.0, min(1.0, data.get('shadow_opacity', 0.8))) - data['shadow_offset_x'] = max(-500, min(500, data.get('shadow_offset_x', 10))) - data['shadow_offset_y'] = max(-500, min(500, data.get('shadow_offset_y', 10))) - data['glow_size'] = max(0, min(500, int(data.get('glow_size', 150)))) - data['glow_spread'] = max(0, min(500, int(data.get('glow_spread', 250)))) - data['glow_brightness'] = max(0.0, min(1.0, data.get('glow_brightness', 1.0))) - data['supersampling_factor'] = max(2, min(4, data.get('supersampling_factor', 2))) - data['min_font_size'] = max(1, min(100, data.get('min_font_size', 8))) - data['max_font_size'] = max(data['min_font_size'], min(1000, data.get('max_font_size', 500))) - data['font_list'] = font_list - - return {**defaults, **data} - - except json.JSONDecodeError as e: - self.log(f"Error parsing node_data: {e}", level=0) - return defaults - except Exception as e: - self.log(f"Error validating node_data: {e}", level=0) - return defaults - - def _is_simple_rectangle(self, mask: np.ndarray) -> bool: - try: - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - return False - contour = max(contours, key=cv2.contourArea) - epsilon = 0.02 * cv2.arcLength(contour, True) - approx = cv2.approxPolyDP(contour, epsilon, True) - if len(approx) == 4: - area_contour = cv2.contourArea(contour) - area_approx = cv2.contourArea(approx) - return area_approx > 0.9 * area_contour - return False - except Exception as e: - self.log(f"Error checking rectangle: {e}", level=2) - return False - - def get_font_path(self, font_name: str) -> Optional[str]: - current_dir = os.path.dirname(os.path.abspath(__file__)) - local_path = os.path.join(current_dir, "fonts", font_name) - if os.path.isfile(local_path): - return local_path - return None - - @functools.lru_cache(maxsize=32) - 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 FontNotFoundError(f"Font 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 as e: - self.log(f"Error parsing color {color}: {e}", level=2) - return (255, 255, 255, int(alpha * 255)) - - @functools.lru_cache(maxsize=64) - def calculate_text_dimensions(self, font_key: str, text: str, letter_spacing: float, - line_spacing: float, outline_thickness: int) -> Tuple[float, float, List[float], List[float]]: - cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness) - if cache_key in self._text_dim_cache: - return self._text_dim_cache[cache_key] - 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) + letter_spacing * max(0, len(line) - 1) - line_height = (bbox[3] - bbox[1]) * line_spacing - line_heights.append(line_height + outline_thickness * 2) - line_widths.append(line_width + outline_thickness * 2) - result = (sum(line_heights), max(line_widths) if line_widths else 0, line_heights, line_widths) - self._text_dim_cache[cache_key] = result - return result - - def text_fits(self, font_key: str, text: str, max_width: float, max_height: float, - letter_spacing: float, line_spacing: float, outline_thickness: int) -> bool: - total_height, max_line_width, _, _ = self.calculate_text_dimensions( - font_key, text, letter_spacing, line_spacing, outline_thickness - ) - 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, - pbar: Optional[Any] = None) -> Tuple[ImageFont.ImageFont, int]: - def font_key(size): - return f"{font_path or 'default'}:{size}" - if self.text_fits(font_key(max_size), text, max_width, max_height, - config.letter_spacing, config.line_spacing, - config.outline_thickness): - try: - font = self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default() - if font_path 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 - if pbar: - pbar.update(1) - try: - font = self.get_cached_font(font_path, mid) if font_path else ImageFont.load_default() - except: - high = mid - 1 - continue - if self.text_fits(font_key(mid), text, max_width, max_height, - config.letter_spacing, config.line_spacing, - config.outline_thickness): - 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 else ImageFont.load_default() - if font_path 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 _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray: - mask_hash = hash(mask_tensor.cpu().numpy().tobytes()) - cached = self._mask_cache.get(mask_hash) - if cached is not None: - return cached - mask_np = mask_tensor.cpu().numpy() - if mask_np.ndim == 3: - if mask_np.shape[0] in [1, 3, 4]: - mask_np = mask_np[0] - elif mask_np.shape[-1] in [1, 3, 4]: - mask_np = mask_np[:, :, 0] - elif mask_np.ndim == 4: - mask_np = mask_np[0] - if mask_np.ndim == 3: - if mask_np.shape[0] in [1, 3, 4]: - mask_np = mask_np[0] - elif mask_np.shape[-1] in [1, 3, 4]: - mask_np = mask_np[:, :, 0] - if mask_np.dtype in [np.float32, np.float64]: - mask_np = (mask_np * 255).astype(np.uint8) - elif mask_np.dtype != np.uint8: - mask_np = mask_np.astype(np.uint8) - if mask_np.ndim != 2: - mask_np = mask_np.squeeze() - _, mask_np = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY) - self._mask_cache.put(mask_hash, mask_np) - return mask_np - - def calculate_mask_rotation(self, mask_np: np.ndarray) -> float: - mask_hash = hash(mask_np.tobytes()) - if mask_hash in self._contour_cache: - return self._contour_cache[mask_hash] - try: - contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - return 0.0 - contour = max(contours, key=cv2.contourArea) - area = cv2.contourArea(contour) - if area < 10: - return 0.0 - rect = cv2.minAreaRect(contour) - angle = rect[2] - if angle < -45: - angle += 90 - elif angle > 45: - angle -= 90 - result = -angle - self._contour_cache[mask_hash] = result - return result - except Exception as e: - self.log(f"Error calculating mask rotation: {e}", level=2) - return 0.0 - - def _draw_single_line(self, draw, position, text, font, color, letter_spacing): - x, y = position - current_x = x - for char in text: - draw.text((current_x, y), char, font=font, fill=color) - current_x += draw.textlength(char, font=font) + letter_spacing - - def _render_text_core(self, text: str, config: TextRenderConfig, - container_size: Tuple[int, int]) -> Image.Image: - text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0)) - draw = ImageDraw.Draw(text_layer) - total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions( - f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}", - text, config.letter_spacing, config.line_spacing, - config.outline_thickness - ) - start_y = (container_size[1] - total_h) // 2 - current_y = start_y - lines = text.split('\n') - for line, line_w, line_h in zip(lines, line_widths, line_heights): - if not line.strip(): - current_y += line_h - continue - start_x = (container_size[0] - line_w) // 2 - if config.has_outline: - draw.text((start_x, current_y), line, font=config.font, - fill=config.text_color, stroke_width=config.outline_thickness, - stroke_fill=config.outline_color) - else: - self._draw_single_line(draw, (start_x, current_y), line, config.font, - config.text_color, config.letter_spacing) - current_y += line_h - return text_layer - - def _remap_value(self, ui_value: float, min_actual: float = 0.7, max_actual: float = 1.0) -> float: - return min_actual + (ui_value * (max_actual - min_actual)) - - def _apply_glow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image: - if not config.has_glow or config.glow_size <= 0: - return text_layer - - w, h = text_layer.size - alpha = np.array(text_layer.getchannel("A"), dtype=np.uint8) - _, binary_mask = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY) - binary_inv = cv2.bitwise_not(binary_mask) - distance = cv2.distanceTransform(binary_inv, cv2.DIST_L2, cv2.DIST_MASK_3) - distance = distance.astype(np.float32) - max_distance = float(config.glow_size) - if max_distance > 0: - glow_intensity = np.clip(1.0 - (distance / max_distance), 0, 1) - else: - glow_intensity = np.zeros_like(distance) - if config.glow_spread > 0: - glow_zone = binary_mask.copy() - kernel_size = config.glow_spread * 2 + 1 - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) - glow_zone = cv2.dilate(glow_zone, kernel, iterations=1) - glow_zone = cv2.subtract(glow_zone, binary_mask) - glow_zone_float = glow_zone.astype(np.float32) / 255.0 - glow_intensity = glow_intensity * glow_zone_float - actual_brightness = self._remap_value(config.glow_brightness, 0.7, 1.0) - glow_intensity = glow_intensity * actual_brightness - glow_arr = np.clip(glow_intensity * 255, 0, 255).astype(np.uint8) - glow_rgba = np.zeros((h, w, 4), dtype=np.uint8) - glow_rgba[:, :, 0] = config.glow_color[0] - glow_rgba[:, :, 1] = config.glow_color[1] - glow_rgba[:, :, 2] = config.glow_color[2] - glow_rgba[:, :, 3] = glow_arr - glow_colored = Image.fromarray(glow_rgba, 'RGBA') - result = Image.new("RGBA", (w, h), (0, 0, 0, 0)) - result.paste(glow_colored, (0, 0), glow_colored) - result.paste(text_layer, (0, 0), text_layer) - return result - - def _apply_shadow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image: - alpha = text_layer.getchannel("A") - shadow = Image.new("RGBA", text_layer.size, (0, 0, 0, 0)) - shadow.putalpha(alpha.copy()) - if config.shadow_blur > 0: - blur_radius = int(config.shadow_blur * 20) - if blur_radius > 0: - shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius)) - offset_x, offset_y = config.shadow_offset - shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), - Image.Resampling.BICUBIC, fill=(0, 0, 0, 0)) - shadow_data = np.array(shadow) - mask = shadow_data[:, :, 3] > 0 - actual_shadow_opacity = self._remap_value(config.shadow_opacity, 0.7, 1.0) - shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * actual_shadow_opacity).astype(np.uint8) - shadow_rgb = np.array([config.shadow_color[0], config.shadow_color[1], config.shadow_color[2]], dtype=np.uint8) - shadow_data[mask, :3] = shadow_rgb - shadow_colored = Image.fromarray(shadow_data, 'RGBA') - result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0)) - result.paste(shadow_colored, (0, 0), shadow_colored) - result.paste(text_layer, (0, 0), text_layer) - return result - - def render_text_layer(self, text: str, config: TextRenderConfig, - container_size: Tuple[int, int]) -> Image.Image: - cache_key = f"{text}_{id(config)}_{container_size}_{config.font.size}" - cached = self._render_cache.get(cache_key) - if cached is not None: - return cached - text_layer = self._render_text_core(text, config, container_size) - if config.use_supersampling and config.supersampling_factor > 1: - text_layer = self.quality_renderer.render_with_supersampling( - text, config, container_size, self._render_text_core, config.supersampling_factor - ) - if config.has_glow: - text_layer = self._apply_glow_effect(text_layer, config) - if config.has_shadow: - text_layer = self._apply_shadow_effect(text_layer, config) - if config.use_edge_smoothing: - text_layer = self.quality_renderer.apply_edge_smoothing(text_layer) - self._render_cache.put(cache_key, text_layer) - return text_layer - - def _process_single(self, image, mask, text, font_name, config, padding, - vertical_align, horizontal_align, rotate_with_mask, - min_font_size, max_font_size): - with self.measure_time('mask_process_time'): - image_np = (image.cpu().numpy() * 255).astype(np.uint8) - image_pil = Image.fromarray(image_np, 'RGB') if image_np.ndim == 3 else Image.fromarray(image_np[0], 'RGB') - mask_np = self._prepare_mask_for_opencv(mask) - mask_pil = Image.fromarray(mask_np, 'L') - bbox = mask_pil.getbbox() - if not bbox: - return image - x1, y1, x2, y2 = bbox - available_w = max(1, x2 - x1 - 2 * padding) - available_h = max(1, y2 - y1 - 2 * padding) - font_path = self.get_font_path(font_name) - if font_path is None: - self.log(f"Font not found: {font_name}, using default", level=0) - font_path = self.get_font_path("default") - pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None - with self.measure_time('font_find_time'): - config.font, actual_size = self.find_optimal_font_size( - font_path, text, available_w, available_h, - min_font_size, max_font_size, config, pbar - ) - with self.measure_time('render_time'): - angle = self.calculate_mask_rotation(mask_np) if rotate_with_mask else 0 - diag = math.sqrt(available_w**2 + available_h**2) - temp_size = int(diag * 1.2) + 50 - text_layer = self.render_text_layer(text, config, (temp_size, temp_size)) - if rotate_with_mask and abs(angle) > 0.1: - text_layer = text_layer.rotate(angle, center=(temp_size//2, temp_size//2), - resample=Image.Resampling.BICUBIC, expand=False) - rot_bbox = text_layer.getbbox() - if not rot_bbox: - return image - rx1, ry1, rx2, ry2 = rot_bbox - text_w, text_h = rx2 - rx1, ry2 - ry1 - if rotate_with_mask: - paste_x = int((x1 + x2 - text_w) / 2) - paste_y = int((y1 + y2 - text_h) / 2) - else: - paste_x = {'left': x1 + padding, 'right': x2 - text_w - padding, - 'center': int(x1 + (available_w - text_w) / 2 + padding)}[horizontal_align] - paste_y = {'top': y1 + padding, 'bottom': y2 - text_h - padding, - 'center': int(y1 + (available_h - text_h) / 2 + padding)}[vertical_align] - cropped = text_layer.crop(rot_bbox) - image_rgba = image_pil.convert("RGBA") - overlay = Image.new("RGBA", image_rgba.size, (0, 0, 0, 0)) - overlay.paste(cropped, (paste_x, paste_y), cropped) - result = Image.alpha_composite(image_rgba, overlay) - result_np = np.array(result.convert("RGB")).astype(np.float32) / 255.0 - return torch.from_numpy(result_np).unsqueeze(0) - - def apply_text(self, image: torch.Tensor, mask: torch.Tensor, node_data: str = None, **kwargs) -> Tuple[torch.Tensor]: - with self.measure_time('total_time'): - try: - self._instance_color_cache.clear() - if self._render_cache: - self._render_cache.clear() - self._mask_cache.clear() - self._text_dim_cache.clear() - self._contour_cache.clear() - if not self._validate_inputs(image, mask): - self.log("Input validation failed", level=0) - return (image,) - data = self._parse_and_validate_node_data(node_data) - text = data.get('text', 'Текст') - if not text or not text.strip(): - self.log("Empty text, using default", level=1) - text = "Текст" - font_name = data.get('font_name', 'default') - self.log(f"Rendering text: '{text[:50]}' | Font: {font_name}", level=2) - text_color = data.get('text_color', '#FFFFFF') - outline_thickness = data.get('outline_thickness', 0) - outline_color = data.get('outline_color', '#808080') - rotate_with_mask = data.get('rotate_with_mask', True) - text_opacity = data.get('text_opacity', 1.0) - line_spacing = data.get('line_spacing', 1.0) - letter_spacing = data.get('letter_spacing', 0.0) - enable_shadow = data.get('enable_shadow', False) - shadow_color = data.get('shadow_color', '#333333') - shadow_offset_x = data.get('shadow_offset_x', 10) - shadow_offset_y = data.get('shadow_offset_y', 10) - shadow_blur = data.get('shadow_blur', 0.2) - shadow_opacity = data.get('shadow_opacity', 0.8) - enable_glow = data.get('enable_glow', False) - glow_color = data.get('glow_color', '#FFFFFF') - glow_size = data.get('glow_size', 150) - glow_spread = data.get('glow_spread', 250) - glow_brightness = data.get('glow_brightness', 1.0) - use_supersampling = data.get('use_supersampling', False) - use_edge_smoothing = data.get('use_edge_smoothing', True) - supersampling_factor = data.get('supersampling_factor', 2) - min_font_size = data.get('min_font_size', 8) - max_font_size = data.get('max_font_size', 500) - padding = data.get('padding', 5) - - actual_text_opacity = self._remap_value(text_opacity, 0.7, 1.0) - - config = TextRenderConfig( - font=None, - text_color=self.parse_color(text_color, actual_text_opacity), - outline_color=self.parse_color(outline_color, actual_text_opacity), - outline_thickness=outline_thickness, - letter_spacing=letter_spacing, - line_spacing=line_spacing, - enable_shadow=enable_shadow, - shadow_color=self.parse_color(shadow_color, 1.0), - shadow_offset=(shadow_offset_x, shadow_offset_y), - shadow_blur=shadow_blur, - shadow_opacity=shadow_opacity, - enable_glow=enable_glow, - glow_color=self.parse_color(glow_color, 1.0), - glow_size=glow_size, - glow_spread=glow_spread, - glow_brightness=glow_brightness, - text_opacity=int(actual_text_opacity * 100), - use_supersampling=use_supersampling, - use_edge_smoothing=use_edge_smoothing, - supersampling_factor=supersampling_factor - ) - if image.ndim == 4: - results = [] - for i in range(image.shape[0]): - result = self._process_single( - image[i], mask[i] if mask.ndim == 4 else mask, - text, font_name, config, padding, 'center', 'center', - rotate_with_mask, min_font_size, max_font_size - ) - results.append(result) - result_tensor = torch.cat(results, dim=0) - else: - result_tensor = self._process_single( - image, mask, text, font_name, config, padding, - 'center', 'center', rotate_with_mask, min_font_size, max_font_size - ) - return (result_tensor,) - except Exception as e: - self.log(f"Error in apply_text: {type(e).__name__}: {e}", level=0) - import traceback - traceback.print_exc() - return (image,) - - def __del__(self): - self._font_cache.clear() - self._text_dim_cache.clear() - self._contour_cache.clear() - if self._render_cache: - self._render_cache.clear() - if hasattr(self, '_mask_cache'): - self._mask_cache.clear() - if hasattr(self, '_instance_color_cache'): - self._instance_color_cache.clear() - if torch.cuda.is_available(): - torch.cuda.empty_cache() - -NODE_CLASS_MAPPINGS = {"RS_TextOverlay": RS_TextOverlay} -NODE_DISPLAY_NAME_MAPPINGS = {"RS_TextOverlay": "🦊 RS Text Overlay"} -__all__ = ["RS_TextOverlay", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file diff --git a/Rayko_Text_Overlay_Pro.py b/Rayko_Text_Overlay_Pro.py new file mode 100644 index 0000000..c023162 --- /dev/null +++ b/Rayko_Text_Overlay_Pro.py @@ -0,0 +1,1269 @@ +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"} \ No newline at end of file diff --git a/__init__.py b/__init__.py index fe0a8ec..670ef80 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.25.2" +__version__ = "0.26.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/fonts/AlGhazalia Arab.otf b/fonts/AlGhazalia Arab.otf new file mode 100644 index 0000000000000000000000000000000000000000..d8821d0ee67e750be031f5d2fde5168e5910b7e5 GIT binary patch literal 156476 zcmeFZcYIXU+CTcNnUo=5C`l-pI1@k-L@`yQiJ%mbE>#hY6zPN{klyP|W-{rWkkAoC z6p=^`aOh1uNJJ4us&r`+7{a>WXKy^mbME`T_r34$e(vv|A7Rhlv&(wg_bF@bwPwVa zF(c?1N+K
R >adtScNXjl9rh5otm7Ul%8ZZXQX5#
z>pU9kz~(||=DS)T $c>0<^v tL9&Tk>SsFnDwD(LVr!}`` &O%1p@lopwmcpVI~`MAt-f~~^s-ss>D=Pd
zOVc;k7FK<2D92gWhcI8q&=``JdZs3&%IIMRA(Kh&S@BF}Q1_!M-ESKc7eSxHQ^E@SW
z^TXHY_Zqz=xoK>8pbBicfkD~W-&Sk@vDZdR(l-M~_RbGhNa^4kyzd6;JnE!;+%;WN
z8)oN;PZu1mM~iKi)uXYF5S^Tc9>q}!sOx6qYlm}#4VL`WWQzq_`j&Y<1o`D%JSw+C
zF>=fKdhZV
waOt*TT5=h)QWLQnd)%e;)NJkxwre
z{=0