From d5d5746f1ef9f7bc294c04ff9816d444458b103c Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:36:44 +0300 Subject: [PATCH] Minor code improvements --- Rayko_Text_Overlay.py | 716 ++++++++++++++++++++++++++++++++--------- web/rs_text_overlay.js | 101 ++++-- 2 files changed, 636 insertions(+), 181 deletions(-) diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py index ab72dd4..b7f8f43 100644 --- a/Rayko_Text_Overlay.py +++ b/Rayko_Text_Overlay.py @@ -8,6 +8,12 @@ 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 @@ -17,6 +23,7 @@ except ImportError: 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: @@ -43,6 +50,9 @@ class TextRenderConfig: shadow_offset: Tuple[int, int] shadow_blur: float text_opacity: int = 100 + use_supersampling: bool = False + use_edge_smoothing: bool = True + supersampling_factor: int = 2 @property def has_outline(self) -> bool: @@ -52,39 +62,111 @@ class TextRenderConfig: def has_shadow(self) -> bool: return self.enable_shadow and any(self.shadow_offset) +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, + text_orientation=config.text_orientation, + 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, + text_opacity=config.text_opacity, + use_supersampling=False, + use_edge_smoothing=False + ) + + 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] = {} _color_cache: Dict[str, Tuple[int, int, int, int]] = {} _text_dim_cache: Dict[Tuple[str, str, float, float, float, int, str], Tuple[float, float, List[float], List[float]]] = {} _contour_cache: Dict[int, float] = {} - _font_list: List[str] = None + _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 = [] - default_font_path = os.path.join(fonts_dir, "Arial.ttf") - if os.path.isfile(default_font_path): - font_list.append("default") + font_list = ["default"] + if os.path.isdir(fonts_dir): for f in sorted(os.listdir(fonts_dir)): if f.lower().endswith(('.ttf', '.otf', '.ttc')): - if f not in font_list: - font_list.append(f) - if HAS_FOLDER_PATHS: - try: - system_font_dirs = folder_paths.get_folder_paths("fonts") - if system_font_dirs: - for font_dir in system_font_dirs: - if os.path.isdir(font_dir): - for f in sorted(os.listdir(font_dir)): - if f.lower().endswith(('.ttf', '.otf', '.ttc')) and f not in font_list: - font_list.append(f) - except: - pass - cls._font_list = font_list + font_list.append(f) + return { "required": { "image": ("IMAGE",), @@ -107,7 +189,10 @@ class RS_TextOverlay: "shadow_offset_y": 2, "shadow_blur": 0.0, "shadow_opacity": 0.8, - "font_list": font_list + "font_list": font_list, + "use_supersampling": False, + "use_edge_smoothing": True, + "supersampling_factor": 2 }), "hidden": True }), @@ -121,11 +206,168 @@ class RS_TextOverlay: 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) 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"{prefix} RS_TextOverlay: {message}") + 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 = ["default"] + + 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) + + defaults = { + 'text': 'Текст', + 'font_name': 'default', + '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, + 'text_orientation': 'horizontal', + 'enable_shadow': False, + 'shadow_color': '#333333', + 'shadow_offset_x': 2, + 'shadow_offset_y': 2, + 'shadow_blur': 0.0, + 'shadow_opacity': 0.8, + '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.0))) + data['shadow_opacity'] = max(0.0, min(1.0, data.get('shadow_opacity', 0.8))) + data['shadow_offset_x'] = max(-50, min(50, data.get('shadow_offset_x', 2))) + data['shadow_offset_y'] = max(-50, min(50, data.get('shadow_offset_y', 2))) + 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]: if font_name == "default": @@ -134,25 +376,12 @@ class RS_TextOverlay: if os.path.isfile(default_path): return default_path return None + 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): - self.log(f"Font found locally: {local_path}", level=2) return local_path - if HAS_FOLDER_PATHS: - try: - system_font_dirs = folder_paths.get_folder_paths("fonts") - if system_font_dirs: - for font_dir in system_font_dirs: - system_path = os.path.join(font_dir, font_name) - if os.path.isfile(system_path): - self.log(f"Font found in system: {system_path}", level=2) - return system_path - except: - pass - if os.path.isabs(font_name) and os.path.isfile(font_name): - return font_name - self.log(f"Font not found: {font_name}", level=0) + return None @functools.lru_cache(maxsize=32) @@ -161,7 +390,9 @@ class RS_TextOverlay: if cache_key in self._font_cache: return self._font_cache[cache_key] try: - font = ImageFont.truetype(font_path, size) + 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: @@ -170,27 +401,36 @@ class RS_TextOverlay: 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)) + cache_key = (str(color), alpha) if cache_key in self._color_cache: return self._color_cache[cache_key] + try: if isinstance(color, str): color_str = color.strip() - hex_val = color_str[1:] if color_str.startswith('#') else color_str - if len(hex_val) == 3: - r, g, b = int(hex_val[0]*2, 16), int(hex_val[1]*2, 16), int(hex_val[2]*2, 16) - a = int(alpha * 255) - elif len(hex_val) >= 6: - r, g, b = int(hex_val[0:2], 16), int(hex_val[2:4], 16), int(hex_val[4:6], 16) - a = int(hex_val[6:8], 16) if len(hex_val) >= 8 else int(alpha * 255) + + 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) + result = (r, g, b, a) else: - r, g, b, a = 255, 255, 255, int(alpha * 255) - result = (r, g, b, a) + 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) + result = (r, g, b, a) + else: + result = (255, 255, 255, int(alpha * 255)) else: result = (255, 255, 255, int(alpha * 255)) + self._color_cache[cache_key] = result return result - except: + + 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) @@ -200,18 +440,26 @@ class RS_TextOverlay: cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation) 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) - font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default() + + 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') if text_orientation == "horizontal" else [text.replace('\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 + if text_orientation == "horizontal": bbox = draw.textbbox((0, 0), line, font=font) line_width = draw.textlength(line, font=font) + letter_spacing * max(0, len(line) - 1) @@ -223,8 +471,10 @@ class RS_TextOverlay: line_height = (char_bbox[3] - char_bbox[1] + letter_spacing) * len(line) else: line_width = line_height = 0 + 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 @@ -241,21 +491,41 @@ class RS_TextOverlay: max_width: float, max_height: float, min_size: int, max_size: int, config: TextRenderConfig, pbar: Optional[Any] = None) -> Tuple[ImageFont.ImageFont, int]: - font_key = lambda size: f"{font_path or 'default'}:{size}" + + 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, config.text_orientation): - return self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default(), max_size + 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 - while low <= high: + + iterations = 0 + max_iterations = 50 + + while low <= high and iterations < max_iterations: mid = (low + high) // 2 - if pbar: pbar.update(1) + 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, config.text_orientation): @@ -263,12 +533,27 @@ class RS_TextOverlay: low = mid + 1 else: high = mid - 1 + if best_font is None: - best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default() + 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] @@ -281,40 +566,56 @@ class RS_TextOverlay: 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) - if cv2.contourArea(contour) < 10: + 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: + + 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, text_orientation): x, y = position + if text_orientation == "horizontal": current_x = x for char in text: @@ -327,152 +628,257 @@ class RS_TextOverlay: bbox = draw.textbbox((x, current_y), char, font=font) current_y += (bbox[3] - bbox[1]) + letter_spacing - def render_text_layer(self, text, config, container_size): + def _draw_outline_optimized(self, draw, position, text, font, color, thickness): + x, y = position + + if thickness == 1: + directions = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)] + for dx, dy in directions: + self._draw_single_line(draw, (x + dx, y + dy), text, font, color, 0, "horizontal") + else: + for radius in range(1, thickness + 1): + for angle in range(0, 360, 45): + dx = int(radius * math.cos(math.radians(angle))) + dy = int(radius * math.sin(math.radians(angle))) + self._draw_single_line(draw, (x + dx, y + dy), text, font, color, 0, "horizontal") + + 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, config.text_orientation ) + start_y = (container_size[1] - total_h) // 2 current_y = start_y lines = text.split('\n') if config.text_orientation == "horizontal" else [text.replace('\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: - thickness = config.outline_thickness - steps = max(2, thickness // 2) - for dx in range(-thickness, thickness + 1, steps): - for dy in range(-thickness, thickness + 1, steps): - if dx == 0 and dy == 0: continue - self._draw_single_line(draw, (start_x + dx, current_y + dy), - line, config.font, config.outline_color, - config.letter_spacing, config.text_orientation) + self._draw_outline_optimized(draw, (start_x, current_y), line, config.font, + config.outline_color, config.outline_thickness) + self._draw_single_line(draw, (start_x, current_y), line, config.font, config.text_color, config.letter_spacing, config.text_orientation) + current_y += line_h - if config.has_shadow: - text_layer = self._apply_shadow_effect(text_layer, config) + return text_layer - def _apply_shadow_effect(self, text_layer, config): + 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.has_shadow: + text_layer = self._apply_shadow_effect(text_layer, config) + + 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.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 _apply_shadow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image: alpha = text_layer.getchannel("A") shadow = Image.new("RGBA", text_layer.size, config.shadow_color) shadow.putalpha(alpha) + 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 if offset_x or offset_y: - shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), Image.BICUBIC, fill=(0, 0, 0, 0)) + shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), + Image.Resampling.BICUBIC, fill=(0, 0, 0, 0)) + result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0)) result.paste(shadow, (0, 0), shadow) result.paste(text_layer, (0, 0), text_layer) return result - def _process_single(self, image, mask, text, font_name, config, padding, vertical_align, horizontal_align, - rotate_with_mask, min_font_size, max_font_size): - 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') + 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 - config.font, actual_size = self.find_optimal_font_size(font_path, text, available_w, available_h, min_font_size, max_font_size, config, pbar) - 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.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) + + 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]: - try: - if image is None or image.numel() == 0: - raise ValueError("Input image is empty") - if mask is None or mask.numel() == 0: - raise ValueError("Input mask is empty") - data = {} - if node_data: - try: - data = json.loads(node_data) - except: - pass - text = data.get('text', 'Текст') - font_name = data.get('font_name', 'default') - 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) - text_orientation = data.get('text_orientation', 'horizontal') - enable_shadow = data.get('enable_shadow', False) - shadow_color = data.get('shadow_color', '#333333') - shadow_offset_x = data.get('shadow_offset_x', 2) - shadow_offset_y = data.get('shadow_offset_y', 2) - shadow_blur = data.get('shadow_blur', 0.0) - shadow_opacity = data.get('shadow_opacity', 0.8) - config = TextRenderConfig( - font=None, - text_color=self.parse_color(text_color, text_opacity), - outline_color=self.parse_color(outline_color, text_opacity), - outline_thickness=outline_thickness, - letter_spacing=letter_spacing, - line_spacing=line_spacing, - text_orientation=text_orientation, - enable_shadow=enable_shadow, - shadow_color=self.parse_color(shadow_color, shadow_opacity), - shadow_offset=(shadow_offset_x, shadow_offset_y), - shadow_blur=shadow_blur, - text_opacity=int(text_opacity * 100) - ) - 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, 5, 'center', 'center', rotate_with_mask, 8, 500) - results.append(result) - return (torch.cat(results, dim=0),) - else: - result = self._process_single(image, mask, text, font_name, config, 5, - 'center', 'center', rotate_with_mask, 8, 500) - return (result,) - except Exception as e: - self.log(f"Error: {type(e).__name__}: {e}", level=0) - import traceback - traceback.print_exc() - return (image,) + with self.measure_time('total_time'): + try: + 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) + text_orientation = data.get('text_orientation', 'horizontal') + enable_shadow = data.get('enable_shadow', False) + shadow_color = data.get('shadow_color', '#333333') + shadow_offset_x = data.get('shadow_offset_x', 2) + shadow_offset_y = data.get('shadow_offset_y', 2) + shadow_blur = data.get('shadow_blur', 0.0) + shadow_opacity = data.get('shadow_opacity', 0.8) + 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) + + config = TextRenderConfig( + font=None, + text_color=self.parse_color(text_color, text_opacity), + outline_color=self.parse_color(outline_color, text_opacity), + outline_thickness=outline_thickness, + letter_spacing=letter_spacing, + line_spacing=line_spacing, + text_orientation=text_orientation, + enable_shadow=enable_shadow, + shadow_color=self.parse_color(shadow_color, shadow_opacity), + shadow_offset=(shadow_offset_x, shadow_offset_y), + shadow_blur=shadow_blur, + text_opacity=int(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._color_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 torch.cuda.is_available(): + torch.cuda.empty_cache() NODE_CLASS_MAPPINGS = {"RS_TextOverlay": RS_TextOverlay} NODE_DISPLAY_NAME_MAPPINGS = {"RS_TextOverlay": "🦊 RS Text Overlay"} diff --git a/web/rs_text_overlay.js b/web/rs_text_overlay.js index e1a1b86..5ee6da5 100644 --- a/web/rs_text_overlay.js +++ b/web/rs_text_overlay.js @@ -1,15 +1,18 @@ import { app } from "../../scripts/app.js"; + app.registerExtension({ name: "RaykoTextOverlay", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "RS_TextOverlay") { console.log("🦊 [RS_TextOverlay] JS loaded!"); + const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + this.data = { text: "Текст", - font_name: "Arial.ttf", + font_name: "default", text_color: "#FFFFFF", outline_thickness: 0, outline_color: "#808080", @@ -24,14 +27,19 @@ app.registerExtension({ shadow_offset_y: 2, shadow_blur: 0.0, shadow_opacity: 0.8, - font_list: ["default"] + font_list: ["default"], + use_supersampling: false, + use_edge_smoothing: true, + supersampling_factor: 2 }; + this.rowHeight = 28; this.padding = 10; this.labelWidth = 120; this.targetWidth = 350; this.clickZones = []; this.widgetYPositions = {}; + this.hiddenWidget = this.widgets.find(w => w.name === "node_data"); if (this.hiddenWidget) { this.hiddenWidget.hidden = true; @@ -48,10 +56,12 @@ app.registerExtension({ console.error("🦊 [RS_TextOverlay] Error loading saved ", e); } } + if (this.widgets) { this.widgets.forEach(w => w.hidden = true); } - this.setSize([this.targetWidth, 650]); + + this.setSize([this.targetWidth, 750]); const self = this; this.drawSeparator = function(ctx, text, x, y, w, h) { @@ -77,23 +87,24 @@ app.registerExtension({ this.drawLabel(ctx, "TEXT", pad, y, labelW, rowH * 2); this.drawMultilineField(ctx, this.data.text, pad + labelW, y, inputW, rowH * 2); this.clickZones.push({ type: "text", x: pad + labelW, y: y, w: inputW, h: rowH * 2 }); + this.widgetYPositions["text"] = y; y += rowH * 2 + 5; - // === FONT_NAME === + // === FONT NAME === this.drawLabel(ctx, "FONT", pad, y, labelW, rowH); this.drawComboField(ctx, this.data.font_name, pad + labelW, y, inputW, rowH); this.widgetYPositions["font_name"] = y; this.clickZones.push({ type: "combo", field: "font_name", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === TEXT_COLOR === + // === TEXT COLOR === this.drawLabel(ctx, "TEXT COLOR", pad, y, labelW, rowH); this.drawColorField(ctx, this.data.text_color, pad + labelW, y, inputW, rowH); this.widgetYPositions["text_color"] = y; this.clickZones.push({ type: "color", field: "text_color", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === OUTLINE_THICKNESS === + // === OUTLINE THICKNESS === this.drawLabel(ctx, "OUTLINE THICK", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.outline_thickness, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["outline_thickness"] = y; @@ -102,25 +113,25 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "outline_thickness", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 50, step: 1 }); y += rowH + 5; - // === OUTLINE_COLOR === + // === OUTLINE COLOR === this.drawLabel(ctx, "OUTLINE COLOR", pad, y, labelW, rowH); this.drawColorField(ctx, this.data.outline_color, pad + labelW, y, inputW, rowH); this.widgetYPositions["outline_color"] = y; this.clickZones.push({ type: "color", field: "outline_color", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === FORMATTING TEXT === + // === FORMATTING TEXT SEPARATOR === this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH); y += rowH + 5; - // === ROTATE_WITH_MASK === + // === ROTATE WITH MASK === this.drawLabel(ctx, "ROTATE WITH MASK", pad, y, labelW, rowH); this.drawBooleanField(ctx, this.data.rotate_with_mask, pad + labelW, y, inputW, rowH); this.widgetYPositions["rotate_with_mask"] = y; this.clickZones.push({ type: "boolean", field: "rotate_with_mask", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === TEXT_OPACITY === + // === TEXT OPACITY === this.drawLabel(ctx, "TEXT OPACITY", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.text_opacity, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["text_opacity"] = y; @@ -129,7 +140,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "text_opacity", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); y += rowH + 5; - // === LINE_SPACING === + // === LINE SPACING === this.drawLabel(ctx, "LINE SPACING", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.line_spacing, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["line_spacing"] = y; @@ -138,7 +149,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "line_spacing", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0.5, max: 3, step: 0.1, float: true }); y += rowH + 5; - // === LETTER_SPACING === + // === LETTER SPACING === this.drawLabel(ctx, "LETTER SPACING", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.letter_spacing, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["letter_spacing"] = y; @@ -147,32 +158,32 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "letter_spacing", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -20, max: 100, step: 0.5, float: true }); y += rowH + 5; - // === TEXT_ORIENTATION === + // === TEXT ORIENTATION === this.drawLabel(ctx, "ORIENTATION", pad, y, labelW, rowH); this.drawComboField(ctx, this.data.text_orientation, pad + labelW, y, inputW, rowH); this.widgetYPositions["text_orientation"] = y; this.clickZones.push({ type: "combo", field: "text_orientation", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === SETTING SHADOW === - this.drawSeparator(ctx, "SETTING SHADOW", pad, y, inputW + labelW, rowH); + // === SHADOW SETTINGS SEPARATOR === + this.drawSeparator(ctx, "SHADOW SETTINGS", pad, y, inputW + labelW, rowH); y += rowH + 5; - // === ENABLE_SHADOW === + // === ENABLE SHADOW === this.drawLabel(ctx, "ENABLE SHADOW", pad, y, labelW, rowH); this.drawBooleanField(ctx, this.data.enable_shadow, pad + labelW, y, inputW, rowH); this.widgetYPositions["enable_shadow"] = y; this.clickZones.push({ type: "boolean", field: "enable_shadow", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === SHADOW_COLOR === + // === SHADOW COLOR === this.drawLabel(ctx, "SHADOW COLOR", pad, y, labelW, rowH); this.drawColorField(ctx, this.data.shadow_color, pad + labelW, y, inputW, rowH); this.widgetYPositions["shadow_color"] = y; this.clickZones.push({ type: "color", field: "shadow_color", x: pad + labelW, y: y, w: inputW, h: rowH }); y += rowH + 5; - // === SHADOW_OFFSET_X === + // === SHADOW OFFSET X === this.drawLabel(ctx, "SHADOW OFFSET X", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_x, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_offset_x"] = y; @@ -181,7 +192,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); y += rowH + 5; - // === SHADOW_OFFSET_Y === + // === SHADOW OFFSET Y === this.drawLabel(ctx, "SHADOW OFFSET Y", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_y, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_offset_y"] = y; @@ -190,7 +201,7 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); y += rowH + 5; - // === SHADOW_BLUR === + // === SHADOW BLUR === this.drawLabel(ctx, "SHADOW BLUR", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_blur, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_blur"] = y; @@ -199,14 +210,43 @@ app.registerExtension({ this.clickZones.push({ type: "arrow_right", field: "shadow_blur", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); y += rowH + 5; - // === SHADOW_OPACITY === + // === SHADOW OPACITY === this.drawLabel(ctx, "SHADOW OPACITY", pad, y, labelW, rowH); this.drawNumberFieldWithArrows(ctx, this.data.shadow_opacity, pad + labelW, y, inputW, rowH, arrowW); this.widgetYPositions["shadow_opacity"] = y; this.clickZones.push({ type: "number", field: "shadow_opacity", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 1, float: true }); this.clickZones.push({ type: "arrow_left", field: "shadow_opacity", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); this.clickZones.push({ type: "arrow_right", field: "shadow_opacity", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); - y += rowH + 15; + y += rowH + 5; + + // === QUALITY SETTINGS SEPARATOR === + this.drawSeparator(ctx, "QUALITY SETTINGS", pad, y, inputW + labelW, rowH); + y += rowH + 5; + + // === USE SUPERSAMPLING === + this.drawLabel(ctx, "SUPERSAMPLING", pad, y, labelW, rowH); + this.drawBooleanField(ctx, this.data.use_supersampling, pad + labelW, y, inputW, rowH); + this.widgetYPositions["use_supersampling"] = y; + this.clickZones.push({ type: "boolean", field: "use_supersampling", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === SUPERSAMPLING FACTOR === + if (this.data.use_supersampling) { + this.drawLabel(ctx, "SUPERSAMPLE FACTOR", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.supersampling_factor, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["supersampling_factor"] = y; + this.clickZones.push({ type: "number", field: "supersampling_factor", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 2, max: 4 }); + this.clickZones.push({ type: "arrow_left", field: "supersampling_factor", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 2, max: 4, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "supersampling_factor", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 2, max: 4, step: 1 }); + y += rowH + 5; + } + + // === USE EDGE SMOOTHING === + this.drawLabel(ctx, "EDGE SMOOTHING", pad, y, labelW, rowH); + this.drawBooleanField(ctx, this.data.use_edge_smoothing, pad + labelW, y, inputW, rowH); + this.widgetYPositions["use_edge_smoothing"] = y; + this.clickZones.push({ type: "boolean", field: "use_edge_smoothing", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; const totalH = y + 10; if (this.size[1] < totalH) { @@ -375,12 +415,12 @@ app.registerExtension({ this.showComboSelector = function(fieldName) { const options = { font_name: self.data.font_list || ["default"], - vertical_align: ["top", "center", "bottom"], - horizontal_align: ["left", "center", "right"], text_orientation: ["horizontal", "vertical"] }; - const list = options[fieldName] || []; + + let list = options[fieldName] || []; if (!list.length) return; + const menu = document.createElement("div"); menu.style.cssText = ` position: fixed; @@ -393,6 +433,7 @@ app.registerExtension({ box-shadow: 0 4px 20px rgba(0,0,0,0.5); min-width: 150px; `; + list.forEach(opt => { const item = document.createElement("div"); item.textContent = opt; @@ -415,9 +456,11 @@ app.registerExtension({ }; menu.appendChild(item); }); + const widgetY = self.widgetYPositions[fieldName] || 200; const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || document.querySelector("canvas")?.getBoundingClientRect(); + if (canvasRect && self.pos) { const menuX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; const menuY = canvasRect.top + self.pos[1] + widgetY + self.rowHeight; @@ -427,7 +470,9 @@ app.registerExtension({ menu.style.left = "250px"; menu.style.top = "200px"; } + document.body.appendChild(menu); + setTimeout(() => { const closeHandler = (e) => { if (!menu.contains(e.target)) { @@ -470,7 +515,7 @@ app.registerExtension({ `; const saveBtn = document.createElement('button'); - saveBtn.textContent = '✅'; + saveBtn.textContent = '✅ SAVE'; saveBtn.style.cssText = ` background: #4CAF50; color: #fff; @@ -524,9 +569,11 @@ app.registerExtension({ console.log("🎨 Color changed:", newColor); self.updateUI(); }, { once: false }); + const widgetY = self.widgetYPositions[fieldName] || 200; const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || document.querySelector("canvas")?.getBoundingClientRect(); + if (canvasRect && self.pos) { const pickerX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; const pickerY = canvasRect.top + self.pos[1] + widgetY; @@ -558,6 +605,7 @@ app.registerExtension({ } }); } + const clickOutsideHandler = (e) => { if (e.target !== colorInput) { colorInput.remove(); @@ -600,4 +648,5 @@ app.registerExtension({ } } }); + console.log("🦊 [RS_TextOverlay] Extension initialized"); \ No newline at end of file