diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py index 46f6de3..4dc2171 100644 --- a/Rayko_Text_Overlay.py +++ b/Rayko_Text_Overlay.py @@ -44,12 +44,16 @@ class TextRenderConfig: outline_thickness: int letter_spacing: float line_spacing: float - text_orientation: str 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 @@ -62,6 +66,10 @@ class TextRenderConfig: @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): @@ -101,7 +109,6 @@ class HighQualityTextRenderer: 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, @@ -110,7 +117,12 @@ class HighQualityTextRenderer: shadow_opacity=config.shadow_opacity, text_opacity=config.text_opacity, use_supersampling=False, - use_edge_smoothing=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: @@ -150,8 +162,7 @@ class HighQualityTextRenderer: 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]]] = {} + _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 @@ -162,13 +173,16 @@ class RS_TextOverlay: 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_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",), @@ -176,7 +190,7 @@ class RS_TextOverlay: "node_data": ("STRING", { "default": json.dumps({ "text": "Текст", - "font_name": "default", + "font_name": font_list[0], "text_color": "#FFFFFF", "outline_thickness": 0, "outline_color": "#808080", @@ -184,13 +198,17 @@ class RS_TextOverlay: "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_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, @@ -222,6 +240,7 @@ class RS_TextOverlay: } 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: @@ -249,55 +268,49 @@ class RS_TextOverlay: 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"] + 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': 'default', + 'font_name': font_list[0], 'text_color': '#FFFFFF', 'outline_thickness': 0, 'outline_color': '#808080', @@ -305,13 +318,17 @@ class RS_TextOverlay: '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_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, @@ -332,10 +349,13 @@ class RS_TextOverlay: 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_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(-50, min(50, data.get('shadow_offset_x', 2))) - data['shadow_offset_y'] = max(-50, min(50, data.get('shadow_offset_y', 2))) + 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))) @@ -355,35 +375,23 @@ class RS_TextOverlay: 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": - current_dir = os.path.dirname(os.path.abspath(__file__)) - default_path = os.path.join(current_dir, "fonts", "Arial.ttf") - 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): return local_path - return None @functools.lru_cache(maxsize=32) @@ -404,88 +412,62 @@ class RS_TextOverlay: 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() - 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) + 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) - result = (r, g, b, a) + return (r, g, b, a) else: - result = (255, 255, 255, int(alpha * 255)) + return (255, 255, 255, int(alpha * 255)) else: - result = (255, 255, 255, int(alpha * 255)) - - self._color_cache[cache_key] = result - return result - + 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, - text_orientation: str) -> Tuple[float, float, List[float], List[float]]: - cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation) + 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') if text_orientation == "horizontal" else [text.replace('\n', '')] + 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 - - 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) - line_height = (bbox[3] - bbox[1]) * line_spacing - else: - if line: - char_bbox = draw.textbbox((0, 0), line[0], font=font) - line_width = draw.textlength(line[0], font=font) - line_height = (char_bbox[3] - char_bbox[1] + letter_spacing) * len(line) - else: - line_width = line_height = 0 - + 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, - text_orientation: str) -> bool: + 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, text_orientation + font_key, text, letter_spacing, line_spacing, outline_thickness ) return max_line_width <= max_width and total_height <= max_height @@ -493,13 +475,11 @@ 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]: - 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): + 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'): @@ -508,34 +488,27 @@ class RS_TextOverlay: 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, config.text_orientation): + 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() @@ -544,18 +517,14 @@ class RS_TextOverlay: 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] @@ -568,263 +537,195 @@ 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) 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 - # ✅ ИСПРАВЛЕННАЯ ФУНКЦИЯ — обводка с letter_spacing - def _draw_single_line(self, draw, position, text, font, color, letter_spacing, text_orientation): + def _draw_single_line(self, draw, position, text, font, color, letter_spacing): x, y = position - - if text_orientation == "horizontal": - 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 - else: - current_y = y - for char in text: - draw.text((x, current_y), char, font=font, fill=color) - bbox = draw.textbbox((x, current_y), char, font=font) - current_y += (bbox[3] - bbox[1]) + letter_spacing - - # ✅ ИСПРАВЛЕННАЯ ФУНКЦИЯ — обводка рисуется для КАЖДОЙ буквы с letter_spacing - def _draw_outline_optimized(self, draw, position, text, font, color, thickness, letter_spacing, text_orientation): - x, y = position - - if text_orientation == "horizontal": - current_x = x - for char in text: - # Рисуем обводку для каждой буквы - 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: - draw.text((current_x + dx, y + dy), char, font=font, fill=color) - 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))) - draw.text((current_x + dx, y + dy), char, font=font, fill=color) - - # ✅ Применяем letter_spacing к обводке - current_x += draw.textlength(char, font=font) + letter_spacing - else: - current_y = y - for char in text: - # Рисуем обводку для каждой буквы - 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: - draw.text((x + dx, current_y + dy), char, font=font, fill=color) - 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))) - draw.text((x + dx, current_y + dy), char, font=font, fill=color) - - bbox = draw.textbbox((x, current_y), char, font=font) - current_y += (bbox[3] - bbox[1]) + letter_spacing + 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, config.text_orientation + config.outline_thickness ) - 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', '')] - + 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 - - # ✅ ИСПРАВЛЕНИЕ: передаём letter_spacing в обводку if config.has_outline: - self._draw_outline_optimized(draw, (start_x, current_y), line, config.font, - config.outline_color, config.outline_thickness, - config.letter_spacing, config.text_orientation) - - self._draw_single_line(draw, (start_x, current_y), line, config.font, - config.text_color, config.letter_spacing, config.text_orientation) - + 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 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}" + 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 - cached = self._render_cache.get(cache_key) - if cached is not None: - return cached - - text_layer = self._render_text_core(text, config, container_size) - - # Сначала supersampling - 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_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 + 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)) - - # Применяем цвет и opacity тени shadow_data = np.array(shadow) mask = shadow_data[:, :, 3] > 0 - - # Применяем shadow_opacity к альфа-каналу - shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * config.shadow_opacity).astype(np.uint8) - - # Создаём цвет тени (RGB без alpha) - shadow_rgb = np.array([ - config.shadow_color[0], - config.shadow_color[1], - config.shadow_color[2] - ], dtype=np.uint8) - - # Применяем цвет только к пикселям где есть тень + 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) @@ -833,34 +734,33 @@ class RS_TextOverlay: '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') @@ -868,13 +768,17 @@ class RS_TextOverlay: 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_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) @@ -882,27 +786,30 @@ class RS_TextOverlay: max_font_size = data.get('max_font_size', 500) padding = data.get('padding', 5) - self.log(f"Shadow settings: enable={enable_shadow}, color={shadow_color}, offset=({shadow_offset_x}, {shadow_offset_y}), blur={shadow_blur}, opacity={shadow_opacity}", level=1) + actual_text_opacity = self._remap_value(text_opacity, 0.7, 1.0) config = TextRenderConfig( font=None, - text_color=self.parse_color(text_color, text_opacity), - outline_color=self.parse_color(outline_color, text_opacity), + 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, - text_orientation=text_orientation, 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, - text_opacity=int(text_opacity * 100), + 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]): @@ -918,9 +825,7 @@ class RS_TextOverlay: 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 @@ -929,14 +834,14 @@ class RS_TextOverlay: 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 hasattr(self, '_instance_color_cache'): + self._instance_color_cache.clear() if torch.cuda.is_available(): torch.cuda.empty_cache() diff --git a/web/rs_text_overlay.js b/web/rs_text_overlay.js index 7fe9dd1..be6da37 100644 --- a/web/rs_text_overlay.js +++ b/web/rs_text_overlay.js @@ -4,8 +4,6 @@ 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; @@ -20,13 +18,17 @@ app.registerExtension({ 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_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: ["default"], use_supersampling: false, use_edge_smoothing: true, @@ -53,7 +55,7 @@ app.registerExtension({ this.data = { ...this.data, ...saved }; } } catch (e) { - console.error("🦊 [RS_TextOverlay] Error loading saved data", e); + console.error("Error loading saved data", e); } } @@ -83,28 +85,24 @@ app.registerExtension({ const inputW = this.size[0] - pad*2 - labelW; let y = startY; - // TEXT 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 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 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 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; @@ -113,25 +111,21 @@ 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 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 SEPARATOR this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH); y += rowH + 5; - // 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 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; @@ -140,7 +134,6 @@ 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 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; @@ -149,7 +142,6 @@ 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 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; @@ -158,50 +150,76 @@ 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 - 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 }); + this.drawSeparator(ctx, "GLOW SETTINGS", pad, y, inputW + labelW, rowH); + y += rowH + 5; + + this.drawLabel(ctx, "ENABLE GLOW", pad, y, labelW, rowH); + this.drawBooleanField(ctx, this.data.enable_glow, pad + labelW, y, inputW, rowH); + this.widgetYPositions["enable_glow"] = y; + this.clickZones.push({ type: "boolean", field: "enable_glow", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + this.drawLabel(ctx, "GLOW COLOR", pad, y, labelW, rowH); + this.drawColorField(ctx, this.data.glow_color, pad + labelW, y, inputW, rowH); + this.widgetYPositions["glow_color"] = y; + this.clickZones.push({ type: "color", field: "glow_color", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + this.drawLabel(ctx, "GLOW SIZE", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.glow_size, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["glow_size"] = y; + this.clickZones.push({ type: "number", field: "glow_size", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 500 }); + this.clickZones.push({ type: "arrow_left", field: "glow_size", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "glow_size", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 }); + y += rowH + 5; + + this.drawLabel(ctx, "GLOW SPREAD", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.glow_spread, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["glow_spread"] = y; + this.clickZones.push({ type: "number", field: "glow_spread", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 500 }); + this.clickZones.push({ type: "arrow_left", field: "glow_spread", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "glow_spread", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 }); + y += rowH + 5; + + this.drawLabel(ctx, "GLOW BRIGHTNESS", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.glow_brightness, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["glow_brightness"] = y; + this.clickZones.push({ type: "number", field: "glow_brightness", 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: "glow_brightness", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.02, float: true }); + this.clickZones.push({ type: "arrow_right", field: "glow_brightness", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.02, float: true }); y += rowH + 5; - // SHADOW SETTINGS SEPARATOR this.drawSeparator(ctx, "SHADOW SETTINGS", pad, y, inputW + labelW, rowH); y += rowH + 5; - // 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 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 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; - this.clickZones.push({ type: "number", field: "shadow_offset_x", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -50, max: 50 }); - this.clickZones.push({ type: "arrow_left", field: "shadow_offset_x", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); - 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 }); + this.clickZones.push({ type: "number", field: "shadow_offset_x", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -500, max: 500 }); + this.clickZones.push({ type: "arrow_left", field: "shadow_offset_x", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 }); y += rowH + 5; - // 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; - this.clickZones.push({ type: "number", field: "shadow_offset_y", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -50, max: 50 }); - this.clickZones.push({ type: "arrow_left", field: "shadow_offset_y", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); - 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 }); + this.clickZones.push({ type: "number", field: "shadow_offset_y", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -500, max: 500 }); + this.clickZones.push({ type: "arrow_left", field: "shadow_offset_y", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 }); y += rowH + 5; - // 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; @@ -210,8 +228,7 @@ 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 - this.drawLabel(ctx, "SHADOW OPACITY", pad, y, labelW, rowH); + this.drawLabel(ctx, "SHADOW BRIGHTNESS", 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 }); @@ -219,18 +236,15 @@ app.registerExtension({ 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 + 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); @@ -241,7 +255,6 @@ app.registerExtension({ 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; @@ -256,36 +269,16 @@ app.registerExtension({ this.onMouseDown = function(e, pos, canvas) { if (!this.clickZones.length) return false; - for (const zone of this.clickZones) { const inX = pos[0] >= zone.x && pos[0] <= zone.x + zone.w; const inY = pos[1] >= zone.y && pos[1] <= zone.y + zone.h; if (inX && inY) { - if (zone.type === "text") { - self.showTextInput(e); - return true; - } - if (zone.type === "combo") { - self.showComboSelector(zone.field, e); - return true; - } - if (zone.type === "color") { - self.openColorPicker(zone.field, e); - return true; - } - if (zone.type === "boolean") { - self.data[zone.field] = !self.data[zone.field]; - self.updateUI(); - return true; - } + if (zone.type === "text") { self.showTextInput(e); return true; } + if (zone.type === "combo") { self.showComboSelector(zone.field, e); return true; } + if (zone.type === "color") { self.openColorPicker(zone.field, e); return true; } + if (zone.type === "boolean") { self.data[zone.field] = !self.data[zone.field]; self.updateUI(); return true; } if (zone.type === "number") { - self.showInlineInput(zone.field, e, { - isNumber: true, - min: zone.min, - max: zone.max, - step: zone.step || 1, - float: zone.float || false - }); + self.showInlineInput(zone.field, e, { isNumber: true, min: zone.min, max: zone.max, step: zone.step || 1, float: zone.float || false }); return true; } if (zone.type === "arrow_left") { @@ -309,446 +302,123 @@ app.registerExtension({ return false; }; - // === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ОТРИСОВКИ === this.drawLabel = function(ctx, text, x, y, w, h) { - ctx.fillStyle = "#aaa"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "left"; + ctx.fillStyle = "#aaa"; ctx.font = "11px sans-serif"; ctx.textAlign = "left"; ctx.fillText(text, x, y + h/2 + 4); }; - this.drawMultilineField = function(ctx, value, x, y, w, h) { - ctx.fillStyle = "#222"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "#444"; - ctx.strokeRect(x, y, w, h); - ctx.fillStyle = "#fff"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "left"; - - if (!value || value.trim() === "") { - ctx.fillStyle = "#666"; - ctx.fillText("Enter text...", x + 5, y + h/2 + 4); - return; - } - - const lines = value.split('\n'); - const lineHeight = 14; - const startY = y + 10; - const maxLines = 3; - + ctx.fillStyle = "#222"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#444"; ctx.strokeRect(x, y, w, h); + ctx.fillStyle = "#fff"; ctx.font = "11px sans-serif"; ctx.textAlign = "left"; + if (!value || value.trim() === "") { ctx.fillStyle = "#666"; ctx.fillText("Enter text...", x + 5, y + h/2 + 4); return; } + const lines = value.split('\n'), lineHeight = 14, startY = y + 10, maxLines = 3; for (let i = 0; i < Math.min(lines.length, maxLines); i++) { - let line = lines[i]; - if (line.length > 35) { - line = line.substring(0, 32) + "..."; - } + let line = lines[i]; if (line.length > 35) line = line.substring(0, 32) + "..."; ctx.fillText(line, x + 5, startY + (i * lineHeight)); } - - if (lines.length > maxLines) { - ctx.fillStyle = "#666"; - ctx.fillText("...", x + 5, startY + (maxLines * lineHeight)); - } + if (lines.length > maxLines) { ctx.fillStyle = "#666"; ctx.fillText("...", x + 5, startY + (maxLines * lineHeight)); } }; - this.drawComboField = function(ctx, value, x, y, w, h) { - ctx.fillStyle = "#222"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "#444"; - ctx.strokeRect(x, y, w, h); - ctx.fillStyle = "#fff"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "center"; + ctx.fillStyle = "#222"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#444"; ctx.strokeRect(x, y, w, h); + ctx.fillStyle = "#fff"; ctx.font = "11px sans-serif"; ctx.textAlign = "center"; ctx.fillText(value, x + w/2, y + h/2 + 4); - ctx.fillStyle = "#666"; - ctx.beginPath(); - ctx.moveTo(x + w - 12, y + h/2 - 3); - ctx.lineTo(x + w - 6, y + h/2 - 3); - ctx.lineTo(x + w - 9, y + h/2 + 3); - ctx.fill(); + ctx.fillStyle = "#666"; ctx.beginPath(); ctx.moveTo(x + w - 12, y + h/2 - 3); + ctx.lineTo(x + w - 6, y + h/2 - 3); ctx.lineTo(x + w - 9, y + h/2 + 3); ctx.fill(); }; - this.drawColorField = function(ctx, value, x, y, w, h) { - const hexW = w - 25; - ctx.fillStyle = "#222"; - ctx.fillRect(x, y, hexW, h); - ctx.strokeStyle = "#444"; - ctx.strokeRect(x, y, hexW, h); - ctx.fillStyle = "#fff"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(value, x + hexW/2, y + h/2 + 4); - const colorBoxX = x + hexW + 5; - const colorBoxY = y + 0; - const colorBoxH = h + 0; - ctx.fillStyle = value; - ctx.fillRect(colorBoxX, colorBoxY, 20, colorBoxH); - ctx.strokeStyle = "#fff"; - ctx.lineWidth = 1.5; - ctx.strokeRect(colorBoxX, colorBoxY, 20, colorBoxH); + const hexW = w - 25; ctx.fillStyle = "#222"; ctx.fillRect(x, y, hexW, h); + ctx.strokeStyle = "#444"; ctx.strokeRect(x, y, hexW, h); ctx.fillStyle = "#fff"; + ctx.font = "11px sans-serif"; ctx.textAlign = "center"; ctx.fillText(value, x + hexW/2, y + h/2 + 4); + const colorBoxX = x + hexW + 5, colorBoxY = y, colorBoxH = h; + ctx.fillStyle = value; ctx.fillRect(colorBoxX, colorBoxY, 20, colorBoxH); + ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5; ctx.strokeRect(colorBoxX, colorBoxY, 20, colorBoxH); }; - this.drawNumberFieldWithArrows = function(ctx, value, x, y, w, h, arrowW) { - ctx.fillStyle = "#222"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "#444"; - ctx.strokeRect(x, y, w, h); - ctx.fillStyle = "#4CAF50"; - ctx.beginPath(); - ctx.moveTo(x + 8, y + h/2); - ctx.lineTo(x + 16, y + h/2 - 6); - ctx.lineTo(x + 16, y + h/2 + 6); - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = "#4CAF50"; - ctx.beginPath(); - ctx.moveTo(x + w - 8, y + h/2); - ctx.lineTo(x + w - 16, y + h/2 - 6); - ctx.lineTo(x + w - 16, y + h/2 + 6); - ctx.closePath(); - ctx.fill(); - ctx.fillStyle = "#fff"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "center"; + ctx.fillStyle = "#222"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#444"; ctx.strokeRect(x, y, w, h); + ctx.fillStyle = "#4CAF50"; ctx.beginPath(); ctx.moveTo(x + 8, y + h/2); + ctx.lineTo(x + 16, y + h/2 - 6); ctx.lineTo(x + 16, y + h/2 + 6); ctx.closePath(); ctx.fill(); + ctx.fillStyle = "#4CAF50"; ctx.beginPath(); ctx.moveTo(x + w - 8, y + h/2); + ctx.lineTo(x + w - 16, y + h/2 - 6); ctx.lineTo(x + w - 16, y + h/2 + 6); ctx.closePath(); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "11px sans-serif"; ctx.textAlign = "center"; const displayValue = typeof value === 'number' && value % 1 !== 0 ? value.toFixed(2) : value; ctx.fillText(displayValue, x + w/2, y + h/2 + 4); }; - this.drawBooleanField = function(ctx, value, x, y, w, h) { - ctx.fillStyle = "#222"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "#444"; - ctx.strokeRect(x, y, w, h); - const circleX = x + w - 35; - const circleY = y + h/2; - ctx.fillStyle = value ? "#4CAF50" : "#555"; - ctx.beginPath(); - ctx.arc(circleX, circleY, 8, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#fff"; - ctx.font = "11px sans-serif"; - ctx.textAlign = "left"; + ctx.fillStyle = "#222"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#444"; ctx.strokeRect(x, y, w, h); + const circleX = x + w - 35, circleY = y + h/2; + ctx.fillStyle = value ? "#4CAF50" : "#555"; ctx.beginPath(); + ctx.arc(circleX, circleY, 8, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "11px sans-serif"; ctx.textAlign = "left"; ctx.fillText(value ? "ON" : "OFF", x + 8, y + h/2 + 4); }; - // === POPUP: ВЫПАДАЮЩИЙ СПИСОК — ПОЗИЦИЯ СПРАВА ОТ КЛИКА === this.showComboSelector = function(fieldName, clickEvent) { - const options = { - font_name: self.data.font_list || ["default"], - text_orientation: ["horizontal", "vertical"] - }; - - let list = options[fieldName] || []; - if (!list.length) return; - + const options = { font_name: self.data.font_list || ["default"] }; + let list = options[fieldName] || []; if (!list.length) return; const menu = document.createElement("div"); - menu.style.cssText = ` - position: fixed; - background: #1a1a1a; - border: 1px solid #444; - border-radius: 6px; - max-height: 300px; - overflow-y: auto; - z-index: 10001; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); - min-width: 150px; - `; - + menu.style.cssText = `position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;max-height:300px;overflow-y:auto;z-index:10001;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; - item.style.cssText = ` - padding: 10px 15px; - cursor: pointer; - color: #ddd; - font-size: 12px; - border-bottom: 1px solid #333; - `; - item.onmouseover = () => item.style.background = "#333"; - item.onmouseout = () => item.style.background = "#1a1a1a"; - item.onclick = (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - self.data[fieldName] = opt; - self.updateUI(); - menu.remove(); - }; + const item = document.createElement("div"); item.textContent = opt; + item.style.cssText = `padding:10px 15px;cursor:pointer;color:#ddd;font-size:12px;border-bottom:1px solid #333;`; + item.onmouseover = () => item.style.background = "#333"; item.onmouseout = () => item.style.background = "#1a1a1a"; + item.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); self.data[fieldName] = opt; self.updateUI(); menu.remove(); }; menu.appendChild(item); }); - - if (clickEvent) { - menu.style.left = (clickEvent.clientX + 8) + "px"; - menu.style.top = clickEvent.clientY + "px"; - } else { - menu.style.left = "250px"; - menu.style.top = "200px"; - } - + if (clickEvent) { menu.style.left = (clickEvent.clientX + 8) + "px"; menu.style.top = clickEvent.clientY + "px"; } + else { menu.style.left = "250px"; menu.style.top = "200px"; } document.body.appendChild(menu); - - setTimeout(() => { - const closeHandler = (ev) => { - if (!menu.contains(ev.target)) { - menu.remove(); - document.removeEventListener("mousedown", closeHandler); - } - }; - document.addEventListener("mousedown", closeHandler); - }, 100); + setTimeout(() => { const closeHandler = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 100); }; - - // === POPUP: МНОГОСТРОЧНЫЙ ТЕКСТ — С ФОКУСОМ И ВЫДЕЛЕНИЕМ === this.showTextInput = function(clickEvent) { const currentValue = self.data.text || ''; - const popup = document.createElement('div'); - popup.style.cssText = ` - position: fixed; - z-index: 10002; - background: #1a1a1a; - border: 1px solid #444; - border-radius: 6px; - padding: 10px; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); - `; - - const input = document.createElement('textarea'); - input.value = currentValue; - input.style.cssText = ` - width: 300px; - height: 150px; - background: #222; - color: #fff; - border: 1px solid #444; - border-radius: 4px; - padding: 10px; - font-size: 12px; - resize: none; - display: block; - margin-bottom: 10px; - `; - - const saveBtn = document.createElement('button'); - saveBtn.textContent = '✅ SAVE'; - saveBtn.style.cssText = ` - background: #4CAF50; - color: #fff; - border: none; - border-radius: 4px; - padding: 8px 16px; - font-size: 14px; - cursor: pointer; - float: right; - `; - saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; - saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50"; - - popup.appendChild(input); - popup.appendChild(saveBtn); - - if (clickEvent) { - popup.style.left = (clickEvent.clientX + 8) + 'px'; - popup.style.top = clickEvent.clientY + 'px'; - } - + popup.style.cssText = `position:fixed;z-index:10002;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:10px;box-shadow:0 4px 20px rgba(0,0,0,0.5);`; + const input = document.createElement('textarea'); input.value = currentValue; + input.style.cssText = `width:300px;height:150px;background:#222;color:#fff;border:1px solid #444;border-radius:4px;padding:10px;font-size:12px;resize:none;display:block;margin-bottom:10px;`; + const saveBtn = document.createElement('button'); saveBtn.textContent = 'SAVE'; + saveBtn.style.cssText = `background:#4CAF50;color:#fff;border:none;border-radius:4px;padding:8px 16px;font-size:14px;cursor:pointer;float:right;`; + saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50"; + popup.appendChild(input); popup.appendChild(saveBtn); + if (clickEvent) { popup.style.left = (clickEvent.clientX + 8) + 'px'; popup.style.top = clickEvent.clientY + 'px'; } document.body.appendChild(popup); - - setTimeout(() => { - input.focus(); - setTimeout(() => { - if (currentValue && currentValue.length > 0) { - input.select(); - } - }, 10); - }, 50); - - saveBtn.onclick = (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - self.data.text = input.value; - self.updateUI(); - popup.remove(); - }; - - input.onkeydown = (ev) => { - if (ev.key === 'Enter' && ev.ctrlKey) { - ev.preventDefault(); - self.data.text = input.value; - self.updateUI(); - popup.remove(); - } - }; + setTimeout(() => { input.focus(); setTimeout(() => { if (currentValue && currentValue.length > 0) input.select(); }, 10); }, 50); + saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); self.data.text = input.value; self.updateUI(); popup.remove(); }; + input.onkeydown = (ev) => { if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); self.data.text = input.value; self.updateUI(); popup.remove(); } }; }; - - // === POPUP: ОДНОСТРОЧНЫЙ ВВОД — С ФОКУСОМ И ВЫДЕЛЕНИЕМ === this.showInlineInput = function(fieldName, clickEvent, options = {}) { const currentValue = self.data[fieldName] || ''; - const isNumber = options.isNumber || false; - const min = options.min !== undefined ? options.min : null; - const max = options.max !== undefined ? options.max : null; - const isFloat = options.float || false; - + const isNumber = options.isNumber || false, min = options.min !== undefined ? options.min : null, max = options.max !== undefined ? options.max : null, isFloat = options.float || false; const popup = document.createElement('div'); - popup.style.cssText = ` - position: fixed; - z-index: 10003; - background: #1a1a1a; - border: 1px solid #444; - border-radius: 6px; - padding: 8px 12px; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); - display: flex; - align-items: center; - gap: 8px; - white-space: nowrap; - `; - - const input = document.createElement('input'); - input.type = isNumber ? 'number' : 'text'; - input.value = currentValue; - if (isNumber) { - input.step = options.step || 1; - if (min !== null) input.min = min; - if (max !== null) input.max = max; - } - input.style.cssText = ` - width: 100px; - background: #222; - color: #fff; - border: 1px solid #444; - border-radius: 4px; - padding: 6px 10px; - font-size: 12px; - outline: none; - `; - - const saveBtn = document.createElement('button'); - saveBtn.textContent = '✅'; - saveBtn.title = 'Save'; - saveBtn.style.cssText = ` - background: #4CAF50; - color: #fff; - border: none; - border-radius: 4px; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - min-width: 28px; - `; - saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; - saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50"; - - popup.appendChild(input); - popup.appendChild(saveBtn); - - if (clickEvent) { - popup.style.left = (clickEvent.clientX + 8) + 'px'; - popup.style.top = clickEvent.clientY + 'px'; - } - + popup.style.cssText = `position:fixed;z-index:10003;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;white-space:nowrap;`; + const input = document.createElement('input'); input.type = isNumber ? 'number' : 'text'; input.value = currentValue; + if (isNumber) { input.step = options.step || 1; if (min !== null) input.min = min; if (max !== null) input.max = max; } + input.style.cssText = `width:100px;background:#222;color:#fff;border:1px solid #444;border-radius:4px;padding:6px 10px;font-size:12px;outline:none;`; + const saveBtn = document.createElement('button'); saveBtn.textContent = 'OK'; saveBtn.title = 'Save'; + saveBtn.style.cssText = `background:#4CAF50;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;min-width:28px;`; + saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50"; + popup.appendChild(input); popup.appendChild(saveBtn); + if (clickEvent) { popup.style.left = (clickEvent.clientX + 8) + 'px'; popup.style.top = clickEvent.clientY + 'px'; } document.body.appendChild(popup); - - setTimeout(() => { - input.focus(); - setTimeout(() => { - if (currentValue !== null && currentValue !== undefined && String(currentValue).length > 0) { - input.select(); - } - }, 10); - }, 50); - - const doSave = () => { - let value = input.value; - if (isNumber) { - let num = isFloat ? parseFloat(value) : parseInt(value); - if (isNaN(num)) num = self.data[fieldName]; - if (min !== null) num = Math.max(min, num); - if (max !== null) num = Math.min(max, num); - value = isFloat ? Math.round(num * 100) / 100 : num; - } - self.data[fieldName] = value; - self.updateUI(); - popup.remove(); - }; - + setTimeout(() => { input.focus(); setTimeout(() => { if (currentValue !== null && currentValue !== undefined && String(currentValue).length > 0) input.select(); }, 10); }, 50); + const doSave = () => { let value = input.value; if (isNumber) { let num = isFloat ? parseFloat(value) : parseInt(value); if (isNaN(num)) num = self.data[fieldName]; if (min !== null) num = Math.max(min, num); if (max !== null) num = Math.min(max, num); value = isFloat ? Math.round(num * 100) / 100 : num; } self.data[fieldName] = value; self.updateUI(); popup.remove(); }; saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); }; - input.onkeydown = (ev) => { - if (ev.key === 'Enter') { - ev.preventDefault(); - doSave(); - } - }; - - setTimeout(() => { - const closeHandler = (ev) => { - if (!popup.contains(ev.target)) { - popup.remove(); - document.removeEventListener("mousedown", closeHandler); - } - }; - document.addEventListener("mousedown", closeHandler); - }, 50); - + input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } }; + setTimeout(() => { const closeHandler = (ev) => { if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 50); return popup; }; - - // === НАТИВНЫЙ КОЛОРПИКЕР — ПРОСТОЙ И НАДЁЖНЫЙ === this.openColorPicker = function(fieldName, clickEvent) { const currentColor = self.data[fieldName] || '#FFFFFF'; - - const colorInput = document.createElement('input'); - colorInput.type = 'color'; - colorInput.value = currentColor; - colorInput.style.display = 'none'; + const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = currentColor; colorInput.style.display = 'none'; document.body.appendChild(colorInput); - - colorInput.addEventListener('change', (e) => { - const newColor = e.target.value.toUpperCase(); - self.data[fieldName] = newColor; - self.updateUI(); - colorInput.remove(); - }, { once: true }); - - // Открываем нативный пикер - setTimeout(() => { - if (colorInput.showPicker) { - colorInput.showPicker(); - } else { - colorInput.click(); - } - }, 10); - }; - - this.syncData = function() { - if (this.hiddenWidget) { - this.hiddenWidget.value = JSON.stringify(self.data, null, 2) - .replace(/\\u([0-9a-fA-F]{4})/g, function(match, p1) { - return String.fromCharCode(parseInt(p1, 16)); - }); - } - }; - - this.updateUI = function() { - self.syncData(); - if (self.graph) self.graph.setDirtyCanvas(true, true); - }; - - const onSerialize = this.onSerialize; - this.onSerialize = function(o) { - self.syncData(); - return onSerialize ? onSerialize.apply(this, arguments) : undefined; - }; - - const onExecute = this.onExecute; - this.onExecute = function() { - self.syncData(); - return onExecute ? onExecute.apply(this, arguments) : undefined; + colorInput.addEventListener('change', (e) => { const newColor = e.target.value.toUpperCase(); self.data[fieldName] = newColor; self.updateUI(); colorInput.remove(); }, { once: true }); + setTimeout(() => { if (colorInput.showPicker) colorInput.showPicker(); else colorInput.click(); }, 10); }; + this.syncData = function() { if (this.hiddenWidget) { this.hiddenWidget.value = JSON.stringify(self.data, null, 2).replace(/\\u([0-9a-fA-F]{4})/g, function(match, p1) { return String.fromCharCode(parseInt(p1, 16)); }); } }; + this.updateUI = function() { self.syncData(); if (self.graph) self.graph.setDirtyCanvas(true, true); }; + const onSerialize = this.onSerialize; this.onSerialize = function(o) { self.syncData(); return onSerialize ? onSerialize.apply(this, arguments) : undefined; }; + const onExecute = this.onExecute; this.onExecute = function() { self.syncData(); return onExecute ? onExecute.apply(this, arguments) : undefined; }; return result; }; } } -}); - -console.log("🦊 [RS_TextOverlay] Extension initialized"); \ No newline at end of file +}); \ No newline at end of file