diff --git a/Example/RS Text Overlay Pro.json b/Example/RS Text Overlay Pro.json new file mode 100644 index 0000000..1f1643d --- /dev/null +++ b/Example/RS Text Overlay Pro.json @@ -0,0 +1 @@ +{"id":"ec300d91-5775-45b4-952d-d1c8a58a5691","revision":0,"last_node_id":106,"last_link_id":183,"nodes":[{"id":100,"type":"RS_OverlayPro","pos":[2540,130],"size":[500,730],"flags":{"pinned":true},"order":4,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":169}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[182]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":3,"type":"PreviewImage","pos":[3060,130],"size":[570,730],"flags":{"pinned":true},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":182}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":[183]}],"properties":{"cnr_id":"comfy-core","ver":"0.25.0","Node name for S&R":"PreviewImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":105,"type":"SaveImage","pos":[3650,130],"size":[590,730],"flags":{"pinned":true},"order":6,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":183},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.26.0","Node name for S&R":"SaveImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI"]},{"id":103,"type":"RS_OverlayPro","pos":[1500,130],"size":[500,730],"flags":{"pinned":true},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":178}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[179]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":99,"type":"RS_OverlayPro","pos":[2020,130],"size":[500,730],"flags":{"pinned":true},"order":3,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":179}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[169]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"2bbe4adeb4f9234034c70e3fc9ea0f4154e0eecc","Node name for S&R":"RS_OverlayPro","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":2,"type":"LoadImage","pos":[1070,130],"size":[410,570],"flags":{"pinned":true},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[178]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.25.0","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI_00005_ (2).png","image"]},{"id":106,"type":"Fast Groups Bypasser (rgthree)","pos":[1070,760],"size":[410,100],"flags":{"pinned":true},"order":1,"mode":0,"inputs":[],"outputs":[{"name":"OPT_CONNECTION","type":"*","links":null}],"properties":{"matchColors":"","matchTitle":"","showNav":true,"showAllGraphs":true,"sort":"position","customSortAlphabet":"","toggleRestriction":"default","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"color":"#322","bgcolor":"#533"}],"links":[[169,99,0,100,0,"IMAGE"],[178,2,0,103,0,"IMAGE"],[179,103,0,99,0,"IMAGE"],[182,100,0,3,0,"IMAGE"],[183,3,0,105,0,"IMAGE"]],"groups":[{"id":1,"title":"Overlay 2","bounding":[2010,60,520,810],"color":"#3f789e","flags":{"pinned":true}},{"id":2,"title":"Overlay 3","bounding":[2530,60,520,810],"color":"#3f789e","flags":{"pinned":true}}],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.6934334949441373,"offset":[-463.92423240571884,203.39108182291372]}},"version":0.4} \ No newline at end of file diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py deleted file mode 100644 index 2b19c3e..0000000 --- a/Rayko_Text_Overlay.py +++ /dev/null @@ -1,864 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright 2025-2026 Raykosan (RaykoStudio) -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import torch -import numpy as np -from PIL import Image, ImageDraw, ImageFont, ImageFilter -import cv2 -import math -import functools -from typing import Dict, Tuple, List, Optional, Any -from dataclasses import dataclass -import json -import time -import re -import unicodedata -from contextlib import contextmanager -from collections import OrderedDict - -try: - from comfy.utils import ProgressBar as ComfyProgressBar - HAS_PBAR = True -except ImportError: - HAS_PBAR = False - -def ComfyProgressBar(total: int, desc: str = ""): - class DummyPBar: - def update(self, n: int = 1): pass - def update_absolute(self, n: int): pass - return DummyPBar() - -try: - import folder_paths - HAS_FOLDER_PATHS = True -except ImportError: - HAS_FOLDER_PATHS = False - -class FontNotFoundError(Exception): pass - -@dataclass -class TextRenderConfig: - font: ImageFont.ImageFont - text_color: Tuple[int, int, int, int] - outline_color: Tuple[int, int, int, int] - outline_thickness: int - letter_spacing: float - line_spacing: float - enable_shadow: bool - shadow_color: Tuple[int, int, int, int] - shadow_offset: Tuple[int, int] - shadow_blur: float - shadow_opacity: float - enable_glow: bool = False - glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255) - glow_size: int = 150 - glow_spread: int = 250 - glow_brightness: float = 1.0 - text_opacity: int = 100 - use_supersampling: bool = False - use_edge_smoothing: bool = True - supersampling_factor: int = 2 - - @property - def has_outline(self) -> bool: - return self.outline_thickness > 0 - - @property - def has_shadow(self) -> bool: - return self.enable_shadow - - @property - def has_glow(self) -> bool: - return self.enable_glow - -class LRUCache: - def __init__(self, max_size: int = 100): - self.cache = OrderedDict() - self.max_size = max_size - - def get(self, key): - if key not in self.cache: - return None - self.cache.move_to_end(key) - return self.cache[key] - - def put(self, key, value): - if key in self.cache: - self.cache.move_to_end(key) - self.cache[key] = value - if len(self.cache) > self.max_size: - self.cache.popitem(last=False) - - def clear(self): - self.cache.clear() - - def __len__(self): - return len(self.cache) - -class HighQualityTextRenderer: - @staticmethod - def render_with_supersampling(text: str, config: TextRenderConfig, - container_size: Tuple[int, int], - render_func, scale_factor: int = 2) -> Image.Image: - scaled_size = (container_size[0] * scale_factor, container_size[1] * scale_factor) - - temp_config = TextRenderConfig( - font=None, - text_color=config.text_color, - outline_color=config.outline_color, - outline_thickness=config.outline_thickness * scale_factor, - letter_spacing=config.letter_spacing * scale_factor, - line_spacing=config.line_spacing, - enable_shadow=config.enable_shadow, - shadow_color=config.shadow_color, - shadow_offset=(config.shadow_offset[0] * scale_factor, - config.shadow_offset[1] * scale_factor), - shadow_blur=config.shadow_blur * scale_factor, - shadow_opacity=config.shadow_opacity, - text_opacity=config.text_opacity, - use_supersampling=False, - use_edge_smoothing=False, - enable_glow=config.enable_glow, - glow_color=config.glow_color, - glow_size=config.glow_size * scale_factor, - glow_spread=config.glow_spread * scale_factor, - glow_brightness=config.glow_brightness - ) - - try: - scaled_font_size = int(config.font.size * scale_factor) - temp_config.font = ImageFont.truetype( - config.font.path if hasattr(config.font, 'path') else "default", - scaled_font_size - ) - except: - temp_config.font = config.font - - temp_layer = render_func(text, temp_config, scaled_size) - result = temp_layer.resize(container_size, Image.Resampling.LANCZOS) - return result - - @staticmethod - def apply_edge_smoothing(text_layer: Image.Image) -> Image.Image: - arr = np.array(text_layer) - - if arr.ndim == 3 and arr.shape[2] == 4: - alpha = arr[:, :, 3] - rgb = arr[:, :, :3] - edges = cv2.Canny(alpha, 50, 150) - - if np.any(edges): - smoothed_rgb = cv2.bilateralFilter(rgb, 5, 50, 50) - alpha_smoothed = cv2.GaussianBlur(alpha, (3, 3), 0.5) - edge_mask = edges > 0 - edge_mask_3d = np.stack([edge_mask] * 4, axis=2) - result_arr = np.dstack([smoothed_rgb, alpha_smoothed]) - original_arr = np.dstack([rgb, alpha]) - result = np.where(edge_mask_3d, result_arr, original_arr) - return Image.fromarray(result.astype(np.uint8), 'RGBA') - - return text_layer - -class RS_TextOverlay: - WEB_DIRECTORY = "web" - _font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {} - _text_dim_cache: Dict[Tuple[str, str, float, float, int], Tuple[float, float, List[float], List[float]]] = {} - _contour_cache: Dict[int, float] = {} - _render_cache: LRUCache = None - - _hex_color_pattern = re.compile(r'^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$') - _hex_short_pattern = re.compile(r'^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$') - - @classmethod - def INPUT_TYPES(cls): - current_dir = os.path.dirname(os.path.abspath(__file__)) - fonts_dir = os.path.join(current_dir, "fonts") - font_list = [] - - if os.path.isdir(fonts_dir): - for f in sorted(os.listdir(fonts_dir)): - if f.lower().endswith(('.ttf', '.otf', '.ttc')): - font_list.append(f) - - if not font_list: - font_list = ["Arial.ttf"] - - return { - "required": { - "image": ("IMAGE",), - "mask": ("MASK",), - "node_data": ("STRING", { - "default": json.dumps({ - "text": "Текст", - "font_name": font_list[0], - "text_color": "#FFFFFF", - "outline_thickness": 0, - "outline_color": "#808080", - "rotate_with_mask": True, - "text_opacity": 1.0, - "line_spacing": 1.0, - "letter_spacing": 0.0, - "enable_shadow": False, - "shadow_color": "#333333", - "shadow_offset_x": 10, - "shadow_offset_y": 10, - "shadow_blur": 0.2, - "shadow_opacity": 0.8, - "enable_glow": False, - "glow_color": "#FFFFFF", - "glow_size": 150, - "glow_spread": 250, - "glow_brightness": 1.0, - "font_list": font_list, - "use_supersampling": False, - "use_edge_smoothing": True, - "supersampling_factor": 2 - }), - "hidden": True - }), - }, - "optional": {} - } - - RETURN_TYPES = ("IMAGE",) - FUNCTION = "apply_text" - CATEGORY = "🦊 RaykoStudio" - DESCRIPTION = "Node allows you to overlay text on images using masks" - - def __init__(self): - self.debug = False - self.debug_log = [] - self.quality_renderer = HighQualityTextRenderer() - - if self._render_cache is None: - self.__class__._render_cache = LRUCache(max_size=100) - - self.performance_metrics = { - 'render_time': [], - 'font_find_time': [], - 'mask_process_time': [], - 'total_time': [] - } - - self._mask_cache = LRUCache(max_size=50) - self._instance_color_cache: Dict[str, Tuple[int, int, int, int]] = {} - - def log(self, message: str, level: int = 1): - if self.debug or level <= 0: - timestamp = time.strftime("%H:%M:%S") - prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•") - print(f"[{timestamp}] {prefix} RS_TextOverlay: {message}") - - if self.debug: - self.debug_log.append(f"[{timestamp}] {prefix} {message}") - if len(self.debug_log) > 1000: - self.debug_log = self.debug_log[-500:] - - @contextmanager - def measure_time(self, metric_name: str): - start = time.perf_counter() - yield - elapsed = time.perf_counter() - start - - if metric_name in self.performance_metrics: - self.performance_metrics[metric_name].append(elapsed) - if len(self.performance_metrics[metric_name]) > 100: - self.performance_metrics[metric_name] = self.performance_metrics[metric_name][-50:] - - def _validate_inputs(self, image: torch.Tensor, mask: torch.Tensor) -> bool: - if image is None or mask is None: - self.log("Image or mask is None", level=0) - return False - if image.numel() == 0 or mask.numel() == 0: - self.log("Empty tensor", level=0) - return False - if image.ndim not in [3, 4]: - self.log(f"Invalid image dimensions: {image.ndim}", level=0) - return False - if mask.ndim not in [2, 3, 4]: - self.log(f"Invalid mask dimensions: {mask.ndim}", level=0) - return False - if torch.isnan(image).any() or torch.isinf(image).any(): - self.log("Image contains NaN or Inf values", level=0) - return False - return True - - def _validate_text(self, text: str) -> str: - if not isinstance(text, str): - return "Текст" - if not text.strip(): - return "Текст" - text = unicodedata.normalize('NFKC', text) - text = ''.join(char for char in text if char.isprintable() or char == '\n') - max_length = 1000 - if len(text) > max_length: - text = text[:max_length] + "..." - self.log(f"Text truncated to {max_length} characters", level=1) - return text - - def _parse_and_validate_node_data(self, node_data: str) -> dict: - current_dir = os.path.dirname(os.path.abspath(__file__)) - fonts_dir = os.path.join(current_dir, "fonts") - font_list = [] - - if os.path.isdir(fonts_dir): - for f in sorted(os.listdir(fonts_dir)): - if f.lower().endswith(('.ttf', '.otf', '.ttc')): - font_list.append(f) - - if not font_list: - font_list = ["Arial.ttf"] - - defaults = { - 'text': 'Текст', - 'font_name': font_list[0], - 'text_color': '#FFFFFF', - 'outline_thickness': 0, - 'outline_color': '#808080', - 'rotate_with_mask': True, - 'text_opacity': 1.0, - 'line_spacing': 1.0, - 'letter_spacing': 0.0, - 'enable_shadow': False, - 'shadow_color': '#333333', - 'shadow_offset_x': 10, - 'shadow_offset_y': 10, - 'shadow_blur': 0.2, - 'shadow_opacity': 0.8, - 'enable_glow': False, - 'glow_color': '#FFFFFF', - 'glow_size': 150, - 'glow_spread': 250, - 'glow_brightness': 1.0, - 'font_list': font_list, - 'use_supersampling': False, - 'use_edge_smoothing': True, - 'supersampling_factor': 2, - 'min_font_size': 8, - 'max_font_size': 500, - 'padding': 5 - } - - if not node_data: - return defaults - - try: - data = json.loads(node_data) - - data['text'] = self._validate_text(data.get('text', defaults['text'])) - data['outline_thickness'] = max(0, min(50, data.get('outline_thickness', 0))) - data['text_opacity'] = max(0.0, min(1.0, data.get('text_opacity', 1.0))) - data['line_spacing'] = max(0.5, min(3.0, data.get('line_spacing', 1.0))) - data['letter_spacing'] = max(-20.0, min(100.0, data.get('letter_spacing', 0.0))) - data['shadow_blur'] = max(0.0, min(1.0, data.get('shadow_blur', 0.2))) - data['shadow_opacity'] = max(0.0, min(1.0, data.get('shadow_opacity', 0.8))) - data['shadow_offset_x'] = max(-500, min(500, data.get('shadow_offset_x', 10))) - data['shadow_offset_y'] = max(-500, min(500, data.get('shadow_offset_y', 10))) - data['glow_size'] = max(0, min(500, int(data.get('glow_size', 150)))) - data['glow_spread'] = max(0, min(500, int(data.get('glow_spread', 250)))) - data['glow_brightness'] = max(0.0, min(1.0, data.get('glow_brightness', 1.0))) - data['supersampling_factor'] = max(2, min(4, data.get('supersampling_factor', 2))) - data['min_font_size'] = max(1, min(100, data.get('min_font_size', 8))) - data['max_font_size'] = max(data['min_font_size'], min(1000, data.get('max_font_size', 500))) - data['font_list'] = font_list - - return {**defaults, **data} - - except json.JSONDecodeError as e: - self.log(f"Error parsing node_data: {e}", level=0) - return defaults - except Exception as e: - self.log(f"Error validating node_data: {e}", level=0) - return defaults - - def _is_simple_rectangle(self, mask: np.ndarray) -> bool: - try: - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - return False - contour = max(contours, key=cv2.contourArea) - epsilon = 0.02 * cv2.arcLength(contour, True) - approx = cv2.approxPolyDP(contour, epsilon, True) - if len(approx) == 4: - area_contour = cv2.contourArea(contour) - area_approx = cv2.contourArea(approx) - return area_approx > 0.9 * area_contour - return False - except Exception as e: - self.log(f"Error checking rectangle: {e}", level=2) - return False - - def get_font_path(self, font_name: str) -> Optional[str]: - current_dir = os.path.dirname(os.path.abspath(__file__)) - local_path = os.path.join(current_dir, "fonts", font_name) - if os.path.isfile(local_path): - return local_path - return None - - @functools.lru_cache(maxsize=32) - def get_cached_font(self, font_path: str, size: int) -> ImageFont.ImageFont: - cache_key = (font_path, size) - if cache_key in self._font_cache: - return self._font_cache[cache_key] - try: - font = ImageFont.truetype(font_path, size, layout_engine=ImageFont.Layout.BASIC) - font.path = font_path - font.size = size - self._font_cache[cache_key] = font - return font - except Exception as e: - raise FontNotFoundError(f"Font error: {e}") - - def parse_color(self, color: Any, alpha: float = 1.0) -> Tuple[int, int, int, int]: - if color is None: - return (255, 255, 255, int(alpha * 255)) - - try: - if isinstance(color, str): - color_str = color.strip() - match = self._hex_color_pattern.match(color_str) - if match: - r, g, b = int(match.group(1), 16), int(match.group(2), 16), int(match.group(3), 16) - a = int(match.group(4), 16) if match.group(4) else int(alpha * 255) - return (r, g, b, a) - else: - match_short = self._hex_short_pattern.match(color_str) - if match_short: - r, g, b = int(match_short.group(1)*2, 16), int(match_short.group(2)*2, 16), int(match_short.group(3)*2, 16) - a = int(alpha * 255) - return (r, g, b, a) - else: - return (255, 255, 255, int(alpha * 255)) - else: - return (255, 255, 255, int(alpha * 255)) - except Exception as e: - self.log(f"Error parsing color {color}: {e}", level=2) - return (255, 255, 255, int(alpha * 255)) - - @functools.lru_cache(maxsize=64) - def calculate_text_dimensions(self, font_key: str, text: str, letter_spacing: float, - line_spacing: float, outline_thickness: int) -> Tuple[float, float, List[float], List[float]]: - cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness) - if cache_key in self._text_dim_cache: - return self._text_dim_cache[cache_key] - temp_img = Image.new("RGB", (1, 1)) - draw = ImageDraw.Draw(temp_img) - font_path, size_str = font_key.rsplit(':', 1) if ':' in font_key else (font_key, '12') - try: - font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default() - except: - font = ImageFont.load_default() - lines = text.split('\n') - line_heights, line_widths = [], [] - for line in lines: - line = line.replace('\t', ' ') - if not line: - line_heights.append(0) - line_widths.append(0) - continue - bbox = draw.textbbox((0, 0), line, font=font) - line_width = draw.textlength(line, font=font) + letter_spacing * max(0, len(line) - 1) - line_height = (bbox[3] - bbox[1]) * line_spacing - line_heights.append(line_height + outline_thickness * 2) - line_widths.append(line_width + outline_thickness * 2) - result = (sum(line_heights), max(line_widths) if line_widths else 0, line_heights, line_widths) - self._text_dim_cache[cache_key] = result - return result - - def text_fits(self, font_key: str, text: str, max_width: float, max_height: float, - letter_spacing: float, line_spacing: float, outline_thickness: int) -> bool: - total_height, max_line_width, _, _ = self.calculate_text_dimensions( - font_key, text, letter_spacing, line_spacing, outline_thickness - ) - return max_line_width <= max_width and total_height <= max_height - - def find_optimal_font_size(self, font_path: Optional[str], text: str, - max_width: float, max_height: float, - min_size: int, max_size: int, config: TextRenderConfig, - pbar: Optional[Any] = None) -> Tuple[ImageFont.ImageFont, int]: - def font_key(size): - return f"{font_path or 'default'}:{size}" - if self.text_fits(font_key(max_size), text, max_width, max_height, - config.letter_spacing, config.line_spacing, - config.outline_thickness): - try: - font = self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default() - if font_path and hasattr(font, 'path'): - font.path = font_path - font.size = max_size - return font, max_size - except: - pass - low, high = min_size, max_size - best_size, best_font = min_size, None - iterations = 0 - max_iterations = 50 - while low <= high and iterations < max_iterations: - mid = (low + high) // 2 - iterations += 1 - if pbar: - pbar.update(1) - try: - font = self.get_cached_font(font_path, mid) if font_path else ImageFont.load_default() - except: - high = mid - 1 - continue - if self.text_fits(font_key(mid), text, max_width, max_height, - config.letter_spacing, config.line_spacing, - config.outline_thickness): - best_font, best_size = font, mid - low = mid + 1 - else: - high = mid - 1 - if best_font is None: - try: - best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default() - if font_path and hasattr(best_font, 'path'): - best_font.path = font_path - best_font.size = min_size - except: - best_font = ImageFont.load_default() - return best_font, best_size - - def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray: - mask_hash = hash(mask_tensor.cpu().numpy().tobytes()) - cached = self._mask_cache.get(mask_hash) - if cached is not None: - return cached - mask_np = mask_tensor.cpu().numpy() - if mask_np.ndim == 3: - if mask_np.shape[0] in [1, 3, 4]: - mask_np = mask_np[0] - elif mask_np.shape[-1] in [1, 3, 4]: - mask_np = mask_np[:, :, 0] - elif mask_np.ndim == 4: - mask_np = mask_np[0] - if mask_np.ndim == 3: - if mask_np.shape[0] in [1, 3, 4]: - mask_np = mask_np[0] - elif mask_np.shape[-1] in [1, 3, 4]: - mask_np = mask_np[:, :, 0] - if mask_np.dtype in [np.float32, np.float64]: - mask_np = (mask_np * 255).astype(np.uint8) - elif mask_np.dtype != np.uint8: - mask_np = mask_np.astype(np.uint8) - if mask_np.ndim != 2: - mask_np = mask_np.squeeze() - _, mask_np = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY) - self._mask_cache.put(mask_hash, mask_np) - return mask_np - - def calculate_mask_rotation(self, mask_np: np.ndarray) -> float: - mask_hash = hash(mask_np.tobytes()) - if mask_hash in self._contour_cache: - return self._contour_cache[mask_hash] - try: - contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - return 0.0 - contour = max(contours, key=cv2.contourArea) - area = cv2.contourArea(contour) - if area < 10: - return 0.0 - rect = cv2.minAreaRect(contour) - angle = rect[2] - if angle < -45: - angle += 90 - elif angle > 45: - angle -= 90 - result = -angle - self._contour_cache[mask_hash] = result - return result - except Exception as e: - self.log(f"Error calculating mask rotation: {e}", level=2) - return 0.0 - - def _draw_single_line(self, draw, position, text, font, color, letter_spacing): - x, y = position - current_x = x - for char in text: - draw.text((current_x, y), char, font=font, fill=color) - current_x += draw.textlength(char, font=font) + letter_spacing - - def _render_text_core(self, text: str, config: TextRenderConfig, - container_size: Tuple[int, int]) -> Image.Image: - text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0)) - draw = ImageDraw.Draw(text_layer) - total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions( - f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}", - text, config.letter_spacing, config.line_spacing, - config.outline_thickness - ) - start_y = (container_size[1] - total_h) // 2 - current_y = start_y - lines = text.split('\n') - for line, line_w, line_h in zip(lines, line_widths, line_heights): - if not line.strip(): - current_y += line_h - continue - start_x = (container_size[0] - line_w) // 2 - if config.has_outline: - draw.text((start_x, current_y), line, font=config.font, - fill=config.text_color, stroke_width=config.outline_thickness, - stroke_fill=config.outline_color) - else: - self._draw_single_line(draw, (start_x, current_y), line, config.font, - config.text_color, config.letter_spacing) - current_y += line_h - return text_layer - - def _remap_value(self, ui_value: float, min_actual: float = 0.7, max_actual: float = 1.0) -> float: - return min_actual + (ui_value * (max_actual - min_actual)) - - def _apply_glow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image: - if not config.has_glow or config.glow_size <= 0: - return text_layer - - w, h = text_layer.size - alpha = np.array(text_layer.getchannel("A"), dtype=np.uint8) - _, binary_mask = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY) - binary_inv = cv2.bitwise_not(binary_mask) - distance = cv2.distanceTransform(binary_inv, cv2.DIST_L2, cv2.DIST_MASK_3) - distance = distance.astype(np.float32) - max_distance = float(config.glow_size) - if max_distance > 0: - glow_intensity = np.clip(1.0 - (distance / max_distance), 0, 1) - else: - glow_intensity = np.zeros_like(distance) - if config.glow_spread > 0: - glow_zone = binary_mask.copy() - kernel_size = config.glow_spread * 2 + 1 - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) - glow_zone = cv2.dilate(glow_zone, kernel, iterations=1) - glow_zone = cv2.subtract(glow_zone, binary_mask) - glow_zone_float = glow_zone.astype(np.float32) / 255.0 - glow_intensity = glow_intensity * glow_zone_float - actual_brightness = self._remap_value(config.glow_brightness, 0.7, 1.0) - glow_intensity = glow_intensity * actual_brightness - glow_arr = np.clip(glow_intensity * 255, 0, 255).astype(np.uint8) - glow_rgba = np.zeros((h, w, 4), dtype=np.uint8) - glow_rgba[:, :, 0] = config.glow_color[0] - glow_rgba[:, :, 1] = config.glow_color[1] - glow_rgba[:, :, 2] = config.glow_color[2] - glow_rgba[:, :, 3] = glow_arr - glow_colored = Image.fromarray(glow_rgba, 'RGBA') - result = Image.new("RGBA", (w, h), (0, 0, 0, 0)) - result.paste(glow_colored, (0, 0), glow_colored) - result.paste(text_layer, (0, 0), text_layer) - return result - - def _apply_shadow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image: - alpha = text_layer.getchannel("A") - shadow = Image.new("RGBA", text_layer.size, (0, 0, 0, 0)) - shadow.putalpha(alpha.copy()) - if config.shadow_blur > 0: - blur_radius = int(config.shadow_blur * 20) - if blur_radius > 0: - shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius)) - offset_x, offset_y = config.shadow_offset - shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), - Image.Resampling.BICUBIC, fill=(0, 0, 0, 0)) - shadow_data = np.array(shadow) - mask = shadow_data[:, :, 3] > 0 - actual_shadow_opacity = self._remap_value(config.shadow_opacity, 0.7, 1.0) - shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * actual_shadow_opacity).astype(np.uint8) - shadow_rgb = np.array([config.shadow_color[0], config.shadow_color[1], config.shadow_color[2]], dtype=np.uint8) - shadow_data[mask, :3] = shadow_rgb - shadow_colored = Image.fromarray(shadow_data, 'RGBA') - result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0)) - result.paste(shadow_colored, (0, 0), shadow_colored) - result.paste(text_layer, (0, 0), text_layer) - return result - - def render_text_layer(self, text: str, config: TextRenderConfig, - container_size: Tuple[int, int]) -> Image.Image: - cache_key = f"{text}_{id(config)}_{container_size}_{config.font.size}" - cached = self._render_cache.get(cache_key) - if cached is not None: - return cached - text_layer = self._render_text_core(text, config, container_size) - if config.use_supersampling and config.supersampling_factor > 1: - text_layer = self.quality_renderer.render_with_supersampling( - text, config, container_size, self._render_text_core, config.supersampling_factor - ) - if config.has_glow: - text_layer = self._apply_glow_effect(text_layer, config) - if config.has_shadow: - text_layer = self._apply_shadow_effect(text_layer, config) - if config.use_edge_smoothing: - text_layer = self.quality_renderer.apply_edge_smoothing(text_layer) - self._render_cache.put(cache_key, text_layer) - return text_layer - - def _process_single(self, image, mask, text, font_name, config, padding, - vertical_align, horizontal_align, rotate_with_mask, - min_font_size, max_font_size): - with self.measure_time('mask_process_time'): - image_np = (image.cpu().numpy() * 255).astype(np.uint8) - image_pil = Image.fromarray(image_np, 'RGB') if image_np.ndim == 3 else Image.fromarray(image_np[0], 'RGB') - mask_np = self._prepare_mask_for_opencv(mask) - mask_pil = Image.fromarray(mask_np, 'L') - bbox = mask_pil.getbbox() - if not bbox: - return image - x1, y1, x2, y2 = bbox - available_w = max(1, x2 - x1 - 2 * padding) - available_h = max(1, y2 - y1 - 2 * padding) - font_path = self.get_font_path(font_name) - if font_path is None: - self.log(f"Font not found: {font_name}, using default", level=0) - font_path = self.get_font_path("default") - pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None - with self.measure_time('font_find_time'): - config.font, actual_size = self.find_optimal_font_size( - font_path, text, available_w, available_h, - min_font_size, max_font_size, config, pbar - ) - with self.measure_time('render_time'): - angle = self.calculate_mask_rotation(mask_np) if rotate_with_mask else 0 - diag = math.sqrt(available_w**2 + available_h**2) - temp_size = int(diag * 1.2) + 50 - text_layer = self.render_text_layer(text, config, (temp_size, temp_size)) - if rotate_with_mask and abs(angle) > 0.1: - text_layer = text_layer.rotate(angle, center=(temp_size//2, temp_size//2), - resample=Image.Resampling.BICUBIC, expand=False) - rot_bbox = text_layer.getbbox() - if not rot_bbox: - return image - rx1, ry1, rx2, ry2 = rot_bbox - text_w, text_h = rx2 - rx1, ry2 - ry1 - if rotate_with_mask: - paste_x = int((x1 + x2 - text_w) / 2) - paste_y = int((y1 + y2 - text_h) / 2) - else: - paste_x = {'left': x1 + padding, 'right': x2 - text_w - padding, - 'center': int(x1 + (available_w - text_w) / 2 + padding)}[horizontal_align] - paste_y = {'top': y1 + padding, 'bottom': y2 - text_h - padding, - 'center': int(y1 + (available_h - text_h) / 2 + padding)}[vertical_align] - cropped = text_layer.crop(rot_bbox) - image_rgba = image_pil.convert("RGBA") - overlay = Image.new("RGBA", image_rgba.size, (0, 0, 0, 0)) - overlay.paste(cropped, (paste_x, paste_y), cropped) - result = Image.alpha_composite(image_rgba, overlay) - result_np = np.array(result.convert("RGB")).astype(np.float32) / 255.0 - return torch.from_numpy(result_np).unsqueeze(0) - - def apply_text(self, image: torch.Tensor, mask: torch.Tensor, node_data: str = None, **kwargs) -> Tuple[torch.Tensor]: - with self.measure_time('total_time'): - try: - self._instance_color_cache.clear() - if self._render_cache: - self._render_cache.clear() - self._mask_cache.clear() - self._text_dim_cache.clear() - self._contour_cache.clear() - if not self._validate_inputs(image, mask): - self.log("Input validation failed", level=0) - return (image,) - data = self._parse_and_validate_node_data(node_data) - text = data.get('text', 'Текст') - if not text or not text.strip(): - self.log("Empty text, using default", level=1) - text = "Текст" - font_name = data.get('font_name', 'default') - self.log(f"Rendering text: '{text[:50]}' | Font: {font_name}", level=2) - text_color = data.get('text_color', '#FFFFFF') - outline_thickness = data.get('outline_thickness', 0) - outline_color = data.get('outline_color', '#808080') - rotate_with_mask = data.get('rotate_with_mask', True) - text_opacity = data.get('text_opacity', 1.0) - line_spacing = data.get('line_spacing', 1.0) - letter_spacing = data.get('letter_spacing', 0.0) - enable_shadow = data.get('enable_shadow', False) - shadow_color = data.get('shadow_color', '#333333') - shadow_offset_x = data.get('shadow_offset_x', 10) - shadow_offset_y = data.get('shadow_offset_y', 10) - shadow_blur = data.get('shadow_blur', 0.2) - shadow_opacity = data.get('shadow_opacity', 0.8) - enable_glow = data.get('enable_glow', False) - glow_color = data.get('glow_color', '#FFFFFF') - glow_size = data.get('glow_size', 150) - glow_spread = data.get('glow_spread', 250) - glow_brightness = data.get('glow_brightness', 1.0) - use_supersampling = data.get('use_supersampling', False) - use_edge_smoothing = data.get('use_edge_smoothing', True) - supersampling_factor = data.get('supersampling_factor', 2) - min_font_size = data.get('min_font_size', 8) - max_font_size = data.get('max_font_size', 500) - padding = data.get('padding', 5) - - actual_text_opacity = self._remap_value(text_opacity, 0.7, 1.0) - - config = TextRenderConfig( - font=None, - text_color=self.parse_color(text_color, actual_text_opacity), - outline_color=self.parse_color(outline_color, actual_text_opacity), - outline_thickness=outline_thickness, - letter_spacing=letter_spacing, - line_spacing=line_spacing, - enable_shadow=enable_shadow, - shadow_color=self.parse_color(shadow_color, 1.0), - shadow_offset=(shadow_offset_x, shadow_offset_y), - shadow_blur=shadow_blur, - shadow_opacity=shadow_opacity, - enable_glow=enable_glow, - glow_color=self.parse_color(glow_color, 1.0), - glow_size=glow_size, - glow_spread=glow_spread, - glow_brightness=glow_brightness, - text_opacity=int(actual_text_opacity * 100), - use_supersampling=use_supersampling, - use_edge_smoothing=use_edge_smoothing, - supersampling_factor=supersampling_factor - ) - if image.ndim == 4: - results = [] - for i in range(image.shape[0]): - result = self._process_single( - image[i], mask[i] if mask.ndim == 4 else mask, - text, font_name, config, padding, 'center', 'center', - rotate_with_mask, min_font_size, max_font_size - ) - results.append(result) - result_tensor = torch.cat(results, dim=0) - else: - result_tensor = self._process_single( - image, mask, text, font_name, config, padding, - 'center', 'center', rotate_with_mask, min_font_size, max_font_size - ) - return (result_tensor,) - except Exception as e: - self.log(f"Error in apply_text: {type(e).__name__}: {e}", level=0) - import traceback - traceback.print_exc() - return (image,) - - def __del__(self): - self._font_cache.clear() - self._text_dim_cache.clear() - self._contour_cache.clear() - if self._render_cache: - self._render_cache.clear() - if hasattr(self, '_mask_cache'): - self._mask_cache.clear() - if hasattr(self, '_instance_color_cache'): - self._instance_color_cache.clear() - if torch.cuda.is_available(): - torch.cuda.empty_cache() - -NODE_CLASS_MAPPINGS = {"RS_TextOverlay": RS_TextOverlay} -NODE_DISPLAY_NAME_MAPPINGS = {"RS_TextOverlay": "🦊 RS Text Overlay"} -__all__ = ["RS_TextOverlay", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file diff --git a/Rayko_Text_Overlay_Pro.py b/Rayko_Text_Overlay_Pro.py new file mode 100644 index 0000000..c023162 --- /dev/null +++ b/Rayko_Text_Overlay_Pro.py @@ -0,0 +1,1269 @@ +from scipy import ndimage +import os +import torch +import numpy as np +from PIL import Image, ImageDraw, ImageFont, ImageFilter +import functools +from typing import Dict, Tuple, List, Optional, Any +from dataclasses import dataclass +import time +import re +from collections import OrderedDict +import folder_paths +from server import PromptServer +from aiohttp import web +import freetype +import cairo +import cv2 +import json +import asyncio + +class LRUCache: + def __init__(self, max_size: int = 100): + self.cache = OrderedDict() + self.max_size = max_size + + def get(self, key): + if key not in self.cache: + return None + self.cache.move_to_end(key) + return self.cache[key] + + def put(self, key, value): + if key in self.cache: + self.cache.move_to_end(key) + self.cache[key] = value + if len(self.cache) > self.max_size: + self.cache.popitem(last=False) + + def clear(self): + self.cache.clear() + +TRANSFORM_CACHE = LRUCache(max_size=50) +PENDING_TRANSFORMS = LRUCache(max_size=50) +RENDER_CACHE = LRUCache(max_size=100) + +GLOBAL_RENDERER = None + +def get_renderer(): + global GLOBAL_RENDERER + if GLOBAL_RENDERER is None: + GLOBAL_RENDERER = RS_OverlayPro() + return GLOBAL_RENDERER + +def safe_font_path(font_name: str) -> Optional[str]: + current_dir = os.path.dirname(os.path.abspath(__file__)) + fonts_dir = os.path.join(current_dir, "fonts") + if os.path.basename(font_name) != font_name: + return None + path = os.path.join(fonts_dir, font_name) + if os.path.isfile(path): + return path + return None + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro") +async def transform_handler(request): + try: + data = await request.json() + node_id = str(data.get("id")) + transforms = data.get("transforms") + text_params = data.get("text_params", {}) + + if node_id is None or transforms is None: + return web.json_response({"error": "Missing data"}, status=400) + + required = {"x", "y", "rotation", "base_width_px", "base_height_px"} + if not all(k in transforms for k in required): + return web.json_response({"error": "Invalid transform data"}, status=400) + + if not isinstance(text_params, dict): + return web.json_response({"error": "text_params must be object"}, status=400) + + TRANSFORM_CACHE.put(node_id, { + **transforms, + "text_params": text_params + }) + + if node_id in PENDING_TRANSFORMS.cache: + PENDING_TRANSFORMS.cache[node_id]["status"] = "completed" + + return web.json_response({"status": "ok"}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.get("/rayko/rs_overlay_pro/get_fonts") +async def get_fonts_handler(request): + try: + renderer = get_renderer() + font_list = renderer._get_font_list() + return web.json_response({"font_list": font_list, "default_font": font_list[0] if font_list else ""}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cancel") +async def cancel_handler(request): + try: + data = await request.json() + node_id = str(data.get("node_id")) + if node_id: + PENDING_TRANSFORMS.cache[node_id] = {"status": "cancelled"} + return web.json_response({"status": "cancelled"}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cleanup") +async def cleanup_handler(request): + try: + data = await request.json() + node_id = str(data.get("node_id")) + TRANSFORM_CACHE.cache.pop(node_id, None) + PENDING_TRANSFORMS.cache.pop(node_id, None) + + temp_dir = folder_paths.get_temp_directory() + for f in os.listdir(temp_dir): + if f.startswith(f"rs_overlay_pro_{node_id}_"): + try: + os.remove(os.path.join(temp_dir, f)) + except: + pass + + return web.json_response({"status": "ok"}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_masks") +async def render_masks_handler(request): + try: + data = await request.json() + text_params = data.get("text_params", {}) + container_width = int(data.get("container_width", 400)) + container_height = int(data.get("container_height", 200)) + node_id = str(data.get("node_id", "unknown")) + + if container_width <= 0 or container_height <= 0: + return web.json_response({"error": "Invalid container size"}, status=400) + + renderer = get_renderer() + masks, render_size = renderer.render_masks_from_params(text_params, (container_width, container_height)) + + ts = int(time.time() * 1000) + result = { + "width": render_size[0], + "height": render_size[1], + "timestamp": ts + } + + for mask_name, mask_img in masks.items(): + filename = f"rs_overlay_pro_{node_id}_{ts}_{mask_name}.png" + filepath = os.path.join(folder_paths.get_temp_directory(), filename) + mask_img.save(filepath) + result[f"{mask_name}_file"] = filename + + return web.json_response(result) + except Exception as e: + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_text") +async def render_text_handler(request): + try: + data = await request.json() + text_params = data.get("text_params", {}) + container_width = int(data.get("container_width", 400)) + container_height = int(data.get("container_height", 200)) + node_id = str(data.get("node_id", "unknown")) + + if container_width <= 0 or container_height <= 0: + return web.json_response({"error": "Invalid container size"}, status=400) + + renderer = get_renderer() + text_layer = renderer.render_text_from_params(text_params, (container_width, container_height)) + + ts = int(time.time() * 1000) + filename = f"rs_overlay_pro_{node_id}_{ts}_text.png" + filepath = os.path.join(folder_paths.get_temp_directory(), filename) + text_layer.save(filepath) + + return web.json_response({ + "filename": filename, + "width": text_layer.width, + "height": text_layer.height, + "timestamp": ts + }) + except Exception as e: + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_glow_mask") +async def render_glow_mask_handler(request): + try: + data = await request.json() + text_mask_file = data.get("text_mask_file") + glow_size = int(data.get("glow_size", 0)) + glow_spread = int(data.get("glow_spread", 0)) + glow_opacity = float(data.get("glow_opacity", 1.0)) + node_id = str(data.get("node_id", "unknown")) + + if not text_mask_file: + return web.json_response({"error": "Missing text_mask_file"}, status=400) + + temp_dir = folder_paths.get_temp_directory() + mask_path = os.path.join(temp_dir, text_mask_file) + + if not os.path.isfile(mask_path): + return web.json_response({"error": "text_mask_file not found"}, status=404) + + cache_key = f"{text_mask_file}_{glow_size}_{glow_spread}_{glow_opacity}" + cached = RENDER_CACHE.get(cache_key) + if cached: + return web.json_response(cached) + + text_mask = Image.open(mask_path).convert("RGBA") + w, h = text_mask.size + + extra_padding = 0 + if glow_size > 0: + extra_padding = int(glow_size * 1.0 + glow_spread * 0.3) + 10 + extra_padding = min(extra_padding, 300) + + expanded_w = w + 2 * extra_padding + expanded_h = h + 2 * extra_padding + expanded_mask = Image.new("RGBA", (expanded_w, expanded_h), (0, 0, 0, 0)) + paste_x = extra_padding + paste_y = extra_padding + expanded_mask.paste(text_mask, (paste_x, paste_y), text_mask) + + renderer = get_renderer() + glow_mask = renderer._apply_glow_to_mask( + expanded_mask, glow_size, glow_spread, glow_opacity + ) + + ts = int(time.time() * 1000) + filename = f"rs_overlay_pro_{node_id}_{ts}_glow.png" + filepath = os.path.join(temp_dir, filename) + glow_mask.save(filepath) + + response_data = { + "glow_file": filename, + "width": glow_mask.width, + "height": glow_mask.height, + "timestamp": ts, + "extra_padding": extra_padding + } + RENDER_CACHE.put(cache_key, response_data) + return web.json_response(response_data) + + except Exception as e: + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_shadow_mask") +async def render_shadow_mask(request): + try: + data = await request.json() + text_mask_file = data.get("text_mask_file") + shadow_blur = int(data.get("shadow_blur", 0)) + node_id = data.get("node_id", "0") + + if not text_mask_file: + return web.json_response({"error": "text_mask_file is required"}, status=400) + + temp_dir = folder_paths.get_temp_directory() + text_mask_path = os.path.join(temp_dir, text_mask_file) + + if not os.path.exists(text_mask_path): + print(f"[RS Overlay Pro] ERROR: file does not exist at {text_mask_path}") + return web.json_response({"error": "text_mask_file does not exist"}, status=404) + + img = Image.open(text_mask_path).convert("L") + + if shadow_blur > 0: + from PIL import ImageFilter + img = img.filter(ImageFilter.GaussianBlur(radius=shadow_blur)) + + rgba = Image.new("RGBA", img.size, (0, 0, 0, 0)) + rgba.putalpha(img) + + timestamp = int(time.time() * 1000) + out_name = f"shadow_{node_id}_{timestamp}.png" + out_path = os.path.join(temp_dir, out_name) + rgba.save(out_path, optimize=True) + + return web.json_response({ + "shadow_file": out_name, + "timestamp": timestamp + }) + + except Exception as e: + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +@dataclass +class TextRenderConfig: + font: ImageFont.ImageFont + font_path: str + font_size: int + text_color: Tuple[int, int, int, int] + outline_color: Tuple[int, int, int, int] + outline_thickness: int + text_opacity: int = 100 + outline_opacity: int = 100 + effect_mode: str = "on" + text_scale_x: float = 1.0 + text_scale_y: float = 1.0 + text_align: str = "left" + line_spacing: float = 1.0 + letter_spacing: float = 0.0 + enable_glow: bool = False + glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255) + glow_size: int = 100 + glow_spread: int = 150 + glow_opacity: float = 1.0 + enable_shadow: bool = False + shadow_color: Tuple[int, int, int, int] = (51, 51, 51, 255) + shadow_offset_x: int = 10 + shadow_offset_y: int = 10 + shadow_opacity: float = 0.8 + shadow_blur: int = 0 + + @property + def has_outline(self) -> bool: + return self.effect_mode == "on" and self.outline_thickness > 0 + +class HybridTextRenderer: + @staticmethod + def render_text_mask_pillow(font_path: str, font_size: int, text: str, + container_size: Tuple[int, int], text_align: str = "left", + padding_x: int = 20, padding_y: int = 25, + line_spacing: float = 1.0, + letter_spacing: float = 0.0) -> Image.Image: + width, height = container_size + text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(text_layer) + + try: + font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC) + except: + font = ImageFont.load_default() + + lines = text.split('\n') + if not lines: + return text_layer + + first_bbox = draw.textbbox((0, 0), lines[0], font=font) + line_height = first_bbox[3] - first_bbox[1] + if line_height == 0: + line_height = font_size + + total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1) + available_height = height - 2 * padding_y + y_start = padding_y + (available_height - total_text_height) / 2 + baseline_offset = -first_bbox[1] + y_start += baseline_offset + + current_y = y_start + + for line in lines: + if not line: + current_y += line_height * line_spacing + continue + + line_bbox = draw.textbbox((0, 0), line, font=font) + line_width = line_bbox[2] - line_bbox[0] + if letter_spacing != 0.0 and len(line) > 1: + line_width += letter_spacing * (len(line) - 1) + + available_width = width - 2 * padding_x + if text_align == "left": + x = padding_x - line_bbox[0] + elif text_align == "center": + x = padding_x + (available_width - line_width) / 2 - line_bbox[0] + elif text_align == "right": + x = width - padding_x - line_width - line_bbox[0] + else: + x = padding_x + (available_width - line_width) / 2 - line_bbox[0] + + if letter_spacing != 0.0 and len(line) > 0: + pen_x = x + for char in line: + draw.text((pen_x, current_y), char, font=font, fill=(255, 255, 255, 255)) + char_width = draw.textlength(char, font=font) + pen_x += char_width + letter_spacing + else: + draw.text((x, current_y), line, font=font, fill=(255, 255, 255, 255)) + + current_y += line_height * line_spacing + + return text_layer + + @staticmethod + def render_outline_mask_pillow(font_path: str, font_size: int, text: str, + outline_thickness: int, container_size: Tuple[int, int], + text_align: str = "left", + padding_x: int = 20, padding_y: int = 25, + line_spacing: float = 1.0, + letter_spacing: float = 0.0) -> Image.Image: + if outline_thickness <= 0: + return Image.new("RGBA", container_size, (0, 0, 0, 0)) + + width, height = container_size + text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(text_layer) + + try: + font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC) + except: + font = ImageFont.load_default() + + lines = text.split('\n') + if not lines: + return text_layer + + first_bbox = draw.textbbox((0, 0), lines[0], font=font) + line_height = first_bbox[3] - first_bbox[1] + if line_height == 0: + line_height = font_size + + total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1) + available_height = height - 2 * padding_y + y_start = padding_y + (available_height - total_text_height) / 2 + baseline_offset = -first_bbox[1] + y_start += baseline_offset + + current_y = y_start + + for line in lines: + if not line: + current_y += line_height * line_spacing + continue + + line_bbox = draw.textbbox((0, 0), line, font=font) + line_width = line_bbox[2] - line_bbox[0] + if letter_spacing != 0.0 and len(line) > 1: + line_width += letter_spacing * (len(line) - 1) + + available_width = width - 2 * padding_x + if text_align == "left": + x = padding_x - line_bbox[0] + elif text_align == "center": + x = padding_x + (available_width - line_width) / 2 - line_bbox[0] + elif text_align == "right": + x = width - padding_x - line_width - line_bbox[0] + else: + x = padding_x + (available_width - line_width) / 2 - line_bbox[0] + + if letter_spacing != 0.0 and len(line) > 0: + pen_x = x + for char in line: + draw.text((pen_x, current_y), char, font=font, + fill=(255, 255, 255, 255), + stroke_width=outline_thickness, + stroke_fill=(255, 255, 255, 255)) + char_width = draw.textlength(char, font=font) + pen_x += char_width + letter_spacing + else: + draw.text((x, current_y), line, font=font, + fill=(255, 255, 255, 255), + stroke_width=outline_thickness, + stroke_fill=(255, 255, 255, 255)) + + current_y += line_height * line_spacing + + return text_layer + + @staticmethod + def apply_cairo_transform(image: Image.Image, scale_x: float, scale_y: float) -> Image.Image: + if abs(scale_x - 1.0) < 0.01 and abs(scale_y - 1.0) < 0.01: + return image + + width, height = image.size + result_width = max(1, int(width * abs(scale_x))) + result_height = max(1, int(height * abs(scale_y))) + + arr = np.array(image) + bgra = np.zeros_like(arr) + bgra[:, :, 0] = arr[:, :, 2] + bgra[:, :, 1] = arr[:, :, 1] + bgra[:, :, 2] = arr[:, :, 0] + bgra[:, :, 3] = arr[:, :, 3] + + data = bgra.tobytes() + source_surface = cairo.ImageSurface.create_for_data( + bytearray(data), cairo.FORMAT_ARGB32, width, height + ) + + result_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, result_width, result_height) + ctx = cairo.Context(result_surface) + ctx.set_source_rgba(0, 0, 0, 0) + ctx.paint() + + ctx.save() + ctx.translate(result_width / 2, result_height / 2) + ctx.scale(scale_x, scale_y) + ctx.translate(-width / 2, -height / 2) + ctx.set_source_surface(source_surface, 0, 0) + ctx.paint() + ctx.restore() + + buf = result_surface.get_data() + arr = np.frombuffer(buf, dtype=np.uint8).reshape(result_height, result_width, 4) + rgba = np.zeros_like(arr) + rgba[:, :, 0] = arr[:, :, 2] + rgba[:, :, 1] = arr[:, :, 1] + rgba[:, :, 2] = arr[:, :, 0] + rgba[:, :, 3] = arr[:, :, 3] + + return Image.fromarray(rgba, 'RGBA') + +class RS_OverlayPro: + WEB_DIRECTORY = "web" + _font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {} + _freetype_cache: Dict[Tuple[str, int], freetype.Face] = {} + _text_dim_cache: LRUCache = LRUCache(max_size=200) + _hex_color_pattern = re.compile(r'^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$') + _hex_short_pattern = re.compile(r'^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$') + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "hidden": { + "unique_id": "UNIQUE_ID" + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "composite" + CATEGORY = "🦊 RaykoStudio" + OUTPUT_NODE = True + DESCRIPTION = "Interactive node for overlaying text on images with real-time positioning, scaling, rotation, and text editing" + + def __init__(self): + self.hybrid_renderer = HybridTextRenderer() + + def get_font_path(self, font_name: str) -> Optional[str]: + return safe_font_path(font_name) + + @functools.lru_cache(maxsize=64) + def get_cached_font(self, font_path: str, size: int) -> ImageFont.ImageFont: + cache_key = (font_path, size) + if cache_key in self._font_cache: + return self._font_cache[cache_key] + + try: + font = ImageFont.truetype(font_path, size, layout_engine=ImageFont.Layout.BASIC) + font.path = font_path + font.size = size + self._font_cache[cache_key] = font + return font + except Exception as e: + raise Exception(f"Font error: {e}") + + @functools.lru_cache(maxsize=64) + def get_freetype_face(self, font_path: str, size: int) -> freetype.Face: + cache_key = (font_path, size) + if cache_key in self._freetype_cache: + return self._freetype_cache[cache_key] + + try: + face = freetype.Face(font_path) + face.set_char_size(size * 64) + self._freetype_cache[cache_key] = face + return face + except Exception as e: + raise Exception(f"FreeType error: {e}") + + def parse_color(self, color: Any, alpha: float = 1.0) -> Tuple[int, int, int, int]: + if color is None: + return (255, 255, 255, int(alpha * 255)) + + try: + if isinstance(color, str): + color_str = color.strip() + match = self._hex_color_pattern.match(color_str) + if match: + r, g, b = int(match.group(1), 16), int(match.group(2), 16), int(match.group(3), 16) + a = int(match.group(4), 16) if match.group(4) else int(alpha * 255) + return (r, g, b, a) + else: + match_short = self._hex_short_pattern.match(color_str) + if match_short: + r, g, b = int(match_short.group(1)*2, 16), int(match_short.group(2)*2, 16), int(match_short.group(3)*2, 16) + a = int(alpha * 255) + return (r, g, b, a) + else: + return (255, 255, 255, int(alpha * 255)) + else: + return (255, 255, 255, int(alpha * 255)) + except Exception: + return (255, 255, 255, int(alpha * 255)) + + def _extract_text_params(self, text_params: dict, container_size: Tuple[int, int]) -> dict: + font_list = self._get_font_list() + text = text_params.get('text', '') + if not text or not text.strip(): + text = '' + + font_name = text_params.get('font_name', '') + if not font_name or font_name not in font_list: + font_name = font_list[0] if font_list else '' + + font_path = self.get_font_path(font_name) + if font_path is None and font_list: + font_path = self.get_font_path(font_list[0]) + + text_color = text_params.get('text_color', '#FFFFFF') + outline_color = text_params.get('outline_color', '#808080') + outline_thickness = int(text_params.get('outline_thickness', 0)) + effect_mode = str(text_params.get('effect_mode', 'on')).lower() + effect_mode = 'on' if effect_mode in ('on', 'off') else 'on' + text_scale_x = float(text_params.get('text_scale_x', 1.0)) + text_scale_y = float(text_params.get('text_scale_y', 1.0)) + text_align = str(text_params.get('text_align', 'left')).lower() + text_align = text_align if text_align in ('left', 'center', 'right') else 'left' + line_spacing = max(0.5, min(3.0, float(text_params.get('line_spacing', 1.0)))) + letter_spacing = max(-20.0, min(100.0, float(text_params.get('letter_spacing', 0.0)))) + min_font_size = int(text_params.get('min_font_size', 4)) + max_font_size = int(text_params.get('max_font_size', 2000)) + text_opacity = min(1.0, max(0.0, float(text_params.get('text_opacity', 1.0)))) + outline_opacity = min(1.0, max(0.0, float(text_params.get('outline_opacity', 1.0)))) + enable_glow = bool(text_params.get('enable_glow', False)) + glow_color = text_params.get('glow_color', '#FFFFFF') + glow_size = int(text_params.get('glow_size', 0)) + glow_spread = int(text_params.get('glow_spread', 0)) + glow_opacity = min(1.0, max(0.0, float(text_params.get('glow_opacity', 1.0)))) + + padding_x = int(container_size[0] * 0.2) # 20% ширины + padding_y = int(container_size[1] * 0.15) # 15% высоты + + return { + 'text': text, + 'font_name': font_name, + 'font_path': font_path, + 'text_color': text_color, + 'outline_color': outline_color, + 'outline_thickness': outline_thickness, + 'effect_mode': effect_mode, + 'text_scale_x': text_scale_x, + 'text_scale_y': text_scale_y, + 'text_align': text_align, + 'line_spacing': line_spacing, + 'letter_spacing': letter_spacing, + 'min_font_size': min_font_size, + 'max_font_size': max_font_size, + 'text_opacity': text_opacity, + 'outline_opacity': outline_opacity, + 'enable_glow': enable_glow, + 'glow_color': glow_color, + 'glow_size': glow_size, + 'glow_spread': glow_spread, + 'glow_opacity': glow_opacity, + 'padding_x': padding_x, + 'padding_y': padding_y, + } + + def calculate_text_dimensions_freetype(self, font_path: str, size: int, text: str, + outline_thickness: int, line_spacing: float = 1.0, + letter_spacing: float = 0.0) -> Tuple[float, float, List[float], List[float]]: + cache_key = (f"{font_path}:{size}", text, outline_thickness, letter_spacing, line_spacing) + cached = self._text_dim_cache.get(cache_key) + if cached is not None: + return cached + + try: + face = self.get_freetype_face(font_path, size) + except: + return self.calculate_text_dimensions_legacy(f"{font_path}:{size}", text, outline_thickness, line_spacing, letter_spacing) + + ascender = face.size.ascender / 64 + descender = face.size.descender / 64 + line_height_fixed = ascender - descender + + lines = text.split('\n') + line_heights = [] + line_widths = [] + + for line in lines: + line = line.replace('\t', ' ') + if not line: + line_heights.append(line_height_fixed + outline_thickness * 2) + line_widths.append(0 + outline_thickness * 2) + continue + + ink_left = float('inf') + ink_right = float('-inf') + pen_x = 0 + prev_glyph_index = 0 + + for i, char in enumerate(line): + try: + face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP) + glyph = face.glyph + metrics = glyph.metrics + + if i > 0 and prev_glyph_index != 0: + kerning = face.get_kerning(prev_glyph_index, glyph.index) + pen_x += kerning.x / 64 + + glyph_left = pen_x + metrics.horiBearingX / 64 + glyph_right = glyph_left + metrics.width / 64 + + ink_left = min(ink_left, glyph_left) + ink_right = max(ink_right, glyph_right) + + pen_x += metrics.horiAdvance / 64 + if letter_spacing != 0.0: + pen_x += letter_spacing + prev_glyph_index = glyph.index + except Exception: + continue + + if ink_left == float('inf'): + line_width = 0 + else: + line_width = ink_right - ink_left + + line_heights.append(line_height_fixed + outline_thickness * 2) + line_widths.append(line_width + outline_thickness * 2) + + if line_heights: + total_height = sum(line_heights) + if len(line_heights) > 1: + avg_line_height = line_heights[0] + total_height += (len(line_heights) - 1) * avg_line_height * (line_spacing - 1) + else: + total_height = 0 + max_width = max(line_widths) if line_widths else 0 + + result = (total_height, max_width, line_heights, line_widths) + self._text_dim_cache.put(cache_key, result) + return result + + def calculate_text_dimensions_legacy(self, font_key: str, text: str, outline_thickness: int, + line_spacing: float = 1.0, letter_spacing: float = 0.0) -> Tuple[float, float, list, list]: + temp_img = Image.new("RGB", (1, 1)) + draw = ImageDraw.Draw(temp_img) + font_path, size_str = font_key.rsplit(':', 1) if ':' in font_key else (font_key, '12') + + try: + font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default() + except: + font = ImageFont.load_default() + + lines = text.split('\n') + line_heights, line_widths = [], [] + + for line in lines: + line = line.replace('\t', ' ') + if not line: + line_heights.append(0) + line_widths.append(0) + continue + + bbox = draw.textbbox((0, 0), line, font=font) + line_width = draw.textlength(line, font=font) + + if letter_spacing != 0.0 and len(line) > 1: + line_width += letter_spacing * (len(line) - 1) + + line_height = (bbox[3] - bbox[1]) + line_heights.append(line_height + outline_thickness * 2) + line_widths.append(line_width + outline_thickness * 2) + + if line_heights: + total_height = sum(line_heights) + if len(line_heights) > 1: + avg_h = line_heights[0] + total_height += (len(line_heights) - 1) * avg_h * (line_spacing - 1) + else: + total_height = 0 + max_width = max(line_widths) if line_widths else 0 + return (total_height, max_width, line_heights, line_widths) + + def text_fits(self, font_path: str, size: int, text: str, max_width: float, max_height: float, + outline_thickness: int, line_spacing: float = 1.0, letter_spacing: float = 0.0) -> bool: + total_height, max_line_width, _, _ = self.calculate_text_dimensions_freetype( + font_path, size, text, outline_thickness, line_spacing, letter_spacing + ) + return max_line_width <= max_width and total_height <= max_height + + def find_optimal_font_size(self, font_path: Optional[str], text: str, + max_width: float, max_height: float, + min_size: int, max_size: int, config: TextRenderConfig) -> Tuple[ImageFont.ImageFont, int]: + if font_path is None: + font_path = "default" + + if self.text_fits(font_path, max_size, text, max_width, max_height, + config.outline_thickness, + config.line_spacing, config.letter_spacing): + try: + font = self.get_cached_font(font_path, max_size) if font_path != "default" else ImageFont.load_default() + if font_path != "default" and hasattr(font, 'path'): + font.path = font_path + font.size = max_size + return font, max_size + except: + pass + + low, high = min_size, max_size + best_size, best_font = min_size, None + iterations = 0 + max_iterations = 50 + + while low <= high and iterations < max_iterations: + mid = (low + high) // 2 + iterations += 1 + + try: + font = self.get_cached_font(font_path, mid) if font_path != "default" else ImageFont.load_default() + except: + high = mid - 1 + continue + + if self.text_fits(font_path, mid, text, max_width, max_height, + config.outline_thickness, + config.line_spacing, config.letter_spacing): + best_font, best_size = font, mid + low = mid + 1 + else: + high = mid - 1 + + if best_font is None: + try: + best_font = self.get_cached_font(font_path, min_size) if font_path != "default" else ImageFont.load_default() + if font_path != "default" and hasattr(best_font, 'path'): + best_font.path = font_path + best_font.size = min_size + except: + best_font = ImageFont.load_default() + + return best_font, best_size + + def _get_font_list(self) -> List[str]: + current_dir = os.path.dirname(os.path.abspath(__file__)) + fonts_dir = os.path.join(current_dir, "fonts") + font_list = [] + + if os.path.isdir(fonts_dir): + for f in sorted(os.listdir(fonts_dir)): + if f.lower().endswith(('.ttf', '.otf', '.ttc')): + font_list.append(f) + + font_list = [f for f in font_list if os.path.basename(f) == f] + + if "Arial.ttf" in font_list: + font_list.remove("Arial.ttf") + font_list.insert(0, "Arial.ttf") + + if not font_list: + font_list = ["Arial.ttf"] + + return font_list + + def render_masks_from_params(self, text_params: dict, container_size: Tuple[int, int]) -> Tuple[Dict[str, Image.Image], Tuple[int, int]]: + p = self._extract_text_params(text_params, container_size) + text = p['text'] + if not text: + empty_mask = Image.new("RGBA", container_size, (0,0,0,0)) + return {'text_mask': empty_mask, 'outline_mask': empty_mask}, container_size + + font_path = p['font_path'] + if font_path is None: + font_list = self._get_font_list() + font_path = self.get_font_path(font_list[0]) + + temp_config = TextRenderConfig( + font=None, + font_path=font_path, + font_size=0, + text_color=(255,255,255,255), + outline_color=(255,255,255,255), + outline_thickness=p['outline_thickness'], + effect_mode=p['effect_mode'], + text_scale_x=p['text_scale_x'], + text_scale_y=p['text_scale_y'], + text_align=p['text_align'], + line_spacing=p['line_spacing'], + letter_spacing=p['letter_spacing'] + ) + + available_w = max(1, container_size[0] - 2 * p['padding_x']) + available_h = max(1, container_size[1] - 2 * p['padding_y']) + + font, actual_size = self.find_optimal_font_size( + font_path, text, available_w, available_h, + p['min_font_size'], p['max_font_size'], temp_config + ) + + text_mask = self.hybrid_renderer.render_text_mask_pillow( + font_path, actual_size, text, container_size, p['text_align'], + p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing'] + ) + text_mask = self.hybrid_renderer.apply_cairo_transform( + text_mask, p['text_scale_x'], p['text_scale_y'] + ) + + masks = {'text_mask': text_mask} + + if p['effect_mode'] == "on" and p['outline_thickness'] > 0: + outline_mask = self.hybrid_renderer.render_outline_mask_pillow( + font_path, actual_size, text, p['outline_thickness'], container_size, + p['text_align'], p['padding_x'], p['padding_y'], + p['line_spacing'], p['letter_spacing'] + ) + text_mask_no_transform = self.hybrid_renderer.render_text_mask_pillow( + font_path, actual_size, text, container_size, p['text_align'], + p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing'] + ) + text_arr = np.array(text_mask_no_transform) + outline_arr = np.array(outline_mask) + text_alpha = text_arr[:, :, 3].astype(np.float32) + outline_alpha = outline_arr[:, :, 3].astype(np.float32) + outline_only_alpha = np.clip(outline_alpha - text_alpha, 0, 255).astype(np.uint8) + + outline_only_arr = np.zeros_like(outline_arr) + outline_only_arr[:, :, 0] = 255 + outline_only_arr[:, :, 1] = 255 + outline_only_arr[:, :, 2] = 255 + outline_only_arr[:, :, 3] = outline_only_alpha + + outline_only_mask = Image.fromarray(outline_only_arr, 'RGBA') + outline_only_mask = self.hybrid_renderer.apply_cairo_transform( + outline_only_mask, p['text_scale_x'], p['text_scale_y'] + ) + masks['outline_mask'] = outline_only_mask + else: + masks['outline_mask'] = Image.new("RGBA", (text_mask.width, text_mask.height), (0,0,0,0)) + + render_size = (text_mask.width, text_mask.height) + return masks, render_size + + def render_text_from_params(self, text_params: dict, container_size: Tuple[int, int], include_glow: bool = False) -> Image.Image: + p = self._extract_text_params(text_params, container_size) + text = p['text'] + if not text: + return Image.new("RGBA", container_size, (0,0,0,0)) + + masks, render_size = self.render_masks_from_params(text_params, container_size) + text_mask = masks['text_mask'] + outline_mask = masks['outline_mask'] + + # Извлекаем shadow-параметры + enable_shadow = bool(text_params.get('enable_shadow', False)) + shadow_color = text_params.get('shadow_color', '#333333') + shadow_opacity = min(1.0, max(0.0, float(text_params.get('shadow_opacity', 0.8)))) + shadow_offset_x = int(text_params.get('shadow_offset_x', 0)) + shadow_offset_y = int(text_params.get('shadow_offset_y', 0)) + shadow_blur = int(text_params.get('shadow_blur', 0)) + + config = TextRenderConfig( + font=None, + font_path=p['font_path'], + font_size=0, + text_color=self.parse_color(p['text_color'], p['text_opacity']), + outline_color=self.parse_color(p['outline_color'], p['outline_opacity']), + outline_thickness=p['outline_thickness'], + effect_mode=p['effect_mode'], + text_scale_x=p['text_scale_x'], + text_scale_y=p['text_scale_y'], + text_align=p['text_align'], + line_spacing=p['line_spacing'], + letter_spacing=p['letter_spacing'], + enable_glow=p['enable_glow'], + glow_color=self.parse_color(p['glow_color'], p['glow_opacity']), + glow_size=p['glow_size'], + glow_spread=p['glow_spread'], + glow_opacity=p['glow_opacity'], + enable_shadow=enable_shadow, + shadow_color=self.parse_color(shadow_color, shadow_opacity), + shadow_offset_x=shadow_offset_x, + shadow_offset_y=shadow_offset_y, + shadow_opacity=shadow_opacity, + shadow_blur=shadow_blur + ) + + result = Image.new("RGBA", render_size, (0,0,0,0)) + + # 1. GLOW + if include_glow and config.enable_glow and config.glow_size > 0: + glow_mask = self._apply_glow_to_mask( + text_mask, config.glow_size, config.glow_spread, config.glow_opacity + ) + glow_colored = self._apply_color_to_mask(glow_mask, config.glow_color) + result = Image.alpha_composite(result, glow_colored) + + # 2. SHADOW (под текстом и outline) + if config.enable_shadow: + shadow_mask = self._apply_shadow_to_mask( + text_mask, config.shadow_blur, config.shadow_offset_x, config.shadow_offset_y + ) + shadow_colored = self._apply_color_to_mask(shadow_mask, config.shadow_color) + result = Image.alpha_composite(result, shadow_colored) + + # 3. OUTLINE + if config.has_outline: + outline_colored = self._apply_color_to_mask(outline_mask, config.outline_color) + result = Image.alpha_composite(result, outline_colored) + + # 4. TEXT + text_colored = self._apply_color_to_mask(text_mask, config.text_color) + result = Image.alpha_composite(result, text_colored) + + return result + + def _apply_glow_to_mask(self, text_mask: Image.Image, glow_size: int, + glow_spread: int, glow_opacity: float) -> Image.Image: + if glow_size <= 0: + return Image.new("RGBA", text_mask.size, (0, 0, 0, 0)) + + w, h = text_mask.size + alpha = np.array(text_mask.getchannel("A"), dtype=np.uint8) + + _, binary = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY) + original_mask = binary.copy() + + if glow_spread > 0: + iterations = max(1, int(glow_spread / 15)) + kernel = np.ones((3, 3), np.uint8) + binary = cv2.dilate(binary, kernel, iterations=iterations) + + sigma = max(0.5, glow_size / 2.0) + blurred = cv2.GaussianBlur(binary, (0, 0), sigmaX=sigma, sigmaY=sigma) + + glow_float = blurred.astype(np.float32) / 255.0 + original_float = original_mask.astype(np.float32) / 255.0 + glow_outer = glow_float * (1.0 - original_float) + glow_outer = glow_outer * glow_opacity + + glow_arr = np.clip(glow_outer * 255, 0, 255).astype(np.uint8) + glow_rgba = np.zeros((h, w, 4), dtype=np.uint8) + glow_rgba[:, :, 3] = glow_arr + + return Image.fromarray(glow_rgba, 'RGBA') + + def _apply_color_to_mask(self, mask: Image.Image, color: Tuple[int, int, int, int]) -> Image.Image: + arr = np.array(mask) + alpha = arr[:, :, 3] + + colored_arr = np.zeros_like(arr) + colored_arr[:, :, 0] = color[0] + colored_arr[:, :, 1] = color[1] + colored_arr[:, :, 2] = color[2] + colored_arr[:, :, 3] = (alpha.astype(np.float32) * (color[3] / 255.0)).astype(np.uint8) + + return Image.fromarray(colored_arr, 'RGBA') + + def _apply_shadow_to_mask(self, text_mask: Image.Image, shadow_blur: int, + shadow_offset_x: int, shadow_offset_y: int) -> Image.Image: + from scipy import ndimage + + w, h = text_mask.size + + alpha = np.array(text_mask.getchannel("A"), dtype=np.float32) + + if shadow_blur > 0: + alpha = ndimage.gaussian_filter(alpha, sigma=shadow_blur / 2.0) + + shadow_alpha = np.zeros((h, w), dtype=np.float32) + + if shadow_offset_x >= 0: + src_x_start, src_x_end = 0, w - shadow_offset_x + dst_x_start, dst_x_end = shadow_offset_x, w + else: + src_x_start, src_x_end = -shadow_offset_x, w + dst_x_start, dst_x_end = 0, w + shadow_offset_x + + if shadow_offset_y >= 0: + src_y_start, src_y_end = 0, h - shadow_offset_y + dst_y_start, dst_y_end = shadow_offset_y, h + else: + src_y_start, src_y_end = -shadow_offset_y, h + dst_y_start, dst_y_end = 0, h + shadow_offset_y + + src_x_start = max(0, src_x_start) + src_x_end = max(0, min(w, src_x_end)) + src_y_start = max(0, src_y_start) + src_y_end = max(0, min(h, src_y_end)) + dst_x_start = max(0, dst_x_start) + dst_x_end = max(0, min(w, dst_x_end)) + dst_y_start = max(0, dst_y_start) + dst_y_end = max(0, min(h, dst_y_end)) + + copy_w = min(src_x_end - src_x_start, dst_x_end - dst_x_start) + copy_h = min(src_y_end - src_y_start, dst_y_end - dst_y_start) + + if copy_w > 0 and copy_h > 0: + shadow_alpha[dst_y_start:dst_y_start+copy_h, dst_x_start:dst_x_start+copy_w] = \ + alpha[src_y_start:src_y_start+copy_h, src_x_start:src_x_start+copy_w] + + shadow_rgba = np.zeros((h, w, 4), dtype=np.uint8) + shadow_rgba[:, :, 3] = np.clip(shadow_alpha, 0, 255).astype(np.uint8) + + return Image.fromarray(shadow_rgba, 'RGBA') + + def _calculate_extra_padding(self, text_params: dict) -> int: + extra = 0 + if text_params.get('enable_glow', False): + glow_size = int(text_params.get('glow_size', 0)) + glow_spread = int(text_params.get('glow_spread', 0)) + if glow_size > 0: + extra = max(extra, int(glow_size * 1.0 + glow_spread * 0.3) + 10) + if text_params.get('enable_shadow', False): + shadow_offset_x = abs(int(text_params.get('shadow_offset_x', 0))) + shadow_offset_y = abs(int(text_params.get('shadow_offset_y', 0))) + shadow_extra = max(shadow_offset_x, shadow_offset_y) + 20 + extra = max(extra, shadow_extra) + extra = min(extra, 300) + return extra + + def tensor2pil(self, tensor): + if len(tensor.shape) == 4: + if tensor.shape[0] > 1: + tensor = tensor[0] + arr = (tensor.cpu().numpy() * 255).astype(np.uint8) + while arr.ndim > 3: + arr = arr[0] + if arr.ndim < 3: + arr = np.expand_dims(arr, axis=-1) + c = arr.shape[2] + if c >= 4: + return Image.fromarray(arr[:,:,:4], 'RGBA') + if c == 3: + return Image.fromarray(arr, 'RGB') + return Image.fromarray(arr[:,:,0], 'L') + + def pil2tensor(self, pil): + if pil.mode == 'L': + pil = pil.convert('RGB') + elif pil.mode != 'RGB': + pass + return torch.from_numpy(np.array(pil).astype(np.float32) / 255.0).unsqueeze(0) + + def composite(self, image, unique_id=None): + unique_id = str(unique_id) if unique_id is not None else "unknown" + + if image.numel() == 0: + return (image,) + + h, w = image.shape[1], image.shape[2] + if h < 8 or w < 8: + return (image,) + + TRANSFORM_CACHE.cache.pop(unique_id, None) + + bg_pil = self.tensor2pil(image) + bg_w, bg_h = bg_pil.size + + font_list = self._get_font_list() + + ts = int(time.time() * 1000) + td = folder_paths.get_temp_directory() + + for f in os.listdir(td): + if f.startswith(f"rs_overlay_pro_{unique_id}_"): + try: + os.remove(os.path.join(td, f)) + except: + pass + + bfn = f"rs_overlay_pro_{unique_id}_{ts}_bg.png" + bg_pil.save(os.path.join(td, bfn)) + + PENDING_TRANSFORMS.cache[unique_id] = {"status": "pending"} + + PromptServer.instance.send_sync("rs-overlay-pro-start", { + "id": unique_id, + "bg_file": bfn, + "bg_width": bg_w, + "bg_height": bg_h, + "timestamp": ts, + "font_list": font_list, + "default_font": font_list[0] + }) + + print(f"[RS Overlay Pro] Node {unique_id} waiting for user input (Apply/Cancel)...") + + timeout = 300 + start_time = time.time() + while unique_id not in TRANSFORM_CACHE.cache: + if unique_id in PENDING_TRANSFORMS.cache and PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled": + break + if time.time() - start_time > timeout: + print(f"[RS Overlay Pro] Timeout for node {unique_id}") + PENDING_TRANSFORMS.cache.pop(unique_id, None) + try: + os.remove(os.path.join(td, bfn)) + except: + pass + return (self.pil2tensor(bg_pil),) + time.sleep(0.2) + + if unique_id in PENDING_TRANSFORMS.cache: + if PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled": + print(f"[RS Overlay Pro] Cancelled by user for node {unique_id}") + PENDING_TRANSFORMS.cache.pop(unique_id, None) + try: + os.remove(os.path.join(td, bfn)) + except: + pass + return (self.pil2tensor(bg_pil),) + + print(f"[RS Overlay Pro] Input received for node {unique_id}") + PENDING_TRANSFORMS.cache.pop(unique_id, None) + + data = TRANSFORM_CACHE.cache.pop(unique_id, None) + if data is None: + return (self.pil2tensor(bg_pil),) + + text_params = data.get("text_params", {}) + + text = text_params.get('text', '') + if not text or not text.strip(): + try: + os.remove(os.path.join(td, bfn)) + except: + pass + return (self.pil2tensor(bg_pil),) + + rx = bg_w / 2 if data.get("x") is None else float(data.get("x")) + ry = bg_h / 2 if data.get("y") is None else float(data.get("y")) + rot = float(data.get("rotation") or 0) + + base_width_px = float(text_params.get('base_width_px', bg_w)) + base_height_px = float(text_params.get('base_height_px', bg_w * 3 / 5)) + base_width_px = max(10, min(bg_w, base_width_px)) + base_height_px = max(10, min(bg_h, base_height_px)) + + text_layer = self.render_text_from_params( + text_params, (int(base_width_px), int(base_height_px)), + include_glow=True + ) + + if rot != 0: + text_layer = text_layer.rotate(-rot, expand=True, resample=Image.Resampling.BICUBIC, + center=(text_layer.width//2, text_layer.height//2)) + + result = bg_pil.copy().convert("RGBA") + paste_x = int(rx - text_layer.width // 2) + paste_y = int(ry - text_layer.height // 2) + result.paste(text_layer, (paste_x, paste_y), text_layer if text_layer.mode == 'RGBA' else None) + + try: + os.remove(os.path.join(td, bfn)) + except: + pass + + return (self.pil2tensor(result.convert("RGB")),) + + @classmethod + def IS_CHANGED(cls, **kwargs): + return time.time() + +NODE_CLASS_MAPPINGS = {"RS_OverlayPro": RS_OverlayPro} +NODE_DISPLAY_NAME_MAPPINGS = {"RS_OverlayPro": "🦊 RS Text Overlay Pro"} \ No newline at end of file diff --git a/__init__.py b/__init__.py index fe0a8ec..670ef80 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.25.2" +__version__ = "0.26.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/fonts/AlGhazalia Arab.otf b/fonts/AlGhazalia Arab.otf new file mode 100644 index 0000000..d8821d0 Binary files /dev/null and b/fonts/AlGhazalia Arab.otf differ diff --git a/fonts/Bbroken Detroit.otf b/fonts/Bbroken Detroit.otf new file mode 100644 index 0000000..014953a Binary files /dev/null and b/fonts/Bbroken Detroit.otf differ diff --git a/fonts/Facon.otf b/fonts/Facon.otf new file mode 100644 index 0000000..27b104b Binary files /dev/null and b/fonts/Facon.otf differ diff --git a/fonts/Fatboy.ttf b/fonts/Fatboy.ttf new file mode 100644 index 0000000..2f9080c Binary files /dev/null and b/fonts/Fatboy.ttf differ diff --git a/fonts/Foglihten.otf b/fonts/Foglihten.otf new file mode 100644 index 0000000..5a3109b Binary files /dev/null and b/fonts/Foglihten.otf differ diff --git a/fonts/Keetano Katakana.ttf b/fonts/Keetano Katakana.ttf new file mode 100644 index 0000000..92a2a7f Binary files /dev/null and b/fonts/Keetano Katakana.ttf differ diff --git a/fonts/Legend.ttf b/fonts/Legend.ttf new file mode 100644 index 0000000..41f95e8 Binary files /dev/null and b/fonts/Legend.ttf differ diff --git a/fonts/Project Space.otf b/fonts/Project Space.otf new file mode 100644 index 0000000..ecc18c3 Binary files /dev/null and b/fonts/Project Space.otf differ diff --git a/fonts/Roboto-Black.ttf b/fonts/Roboto-Black.ttf new file mode 100644 index 0000000..86ec2b2 Binary files /dev/null and b/fonts/Roboto-Black.ttf differ diff --git a/fonts/Roboto-Thin.ttf b/fonts/Roboto-Thin.ttf new file mode 100644 index 0000000..861d63a Binary files /dev/null and b/fonts/Roboto-Thin.ttf differ diff --git a/fonts/RobotoSlab-Bold.ttf b/fonts/RobotoSlab-Bold.ttf new file mode 100644 index 0000000..df5d1df Binary files /dev/null and b/fonts/RobotoSlab-Bold.ttf differ diff --git a/fonts/RobotoSlab-Regular.ttf b/fonts/RobotoSlab-Regular.ttf new file mode 100644 index 0000000..eb52a79 Binary files /dev/null and b/fonts/RobotoSlab-Regular.ttf differ diff --git a/fonts/Runic AltNo.ttf b/fonts/Runic AltNo.ttf new file mode 100644 index 0000000..764c76c Binary files /dev/null and b/fonts/Runic AltNo.ttf differ diff --git a/fonts/Runic.ttf b/fonts/Runic.ttf new file mode 100644 index 0000000..f83dbd5 Binary files /dev/null and b/fonts/Runic.ttf differ diff --git a/fonts/Rusticus.otf b/fonts/Rusticus.otf new file mode 100644 index 0000000..f1cb70a Binary files /dev/null and b/fonts/Rusticus.otf differ diff --git a/fonts/Zenter.otf b/fonts/Zenter.otf new file mode 100644 index 0000000..ac3f0b1 Binary files /dev/null and b/fonts/Zenter.otf differ diff --git a/package.json b/package.json index fb2ddb1..ab8e710 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.25.2", + "version": "0.26.0", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", @@ -32,7 +32,7 @@ "Rayko_Save_Image_Pair.py" "Rayko_Spline_Mask.py" "Rayko_Styles_Loader.py" - "Rayko_Text_Overlay.py" + "Rayko_Text_Overlay_Pro.py" "settings_manager_api.py" ] } diff --git a/pyproject.toml b/pyproject.toml index 00b8564..d687211 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_RaykoStudio" description = "Set of custom nodes for ComfyUI providing additional image processing capabilities" -version = "0.25.2" +version = "0.26.0" license = { file = "LICENSE" } dependencies = ["torch>=1.7.0", "numpy>=1.19.0", "Pillow>=8.0.0", "freetype-py>=2.5.1"] diff --git a/requirements.txt b/requirements.txt index c552d74..35a4999 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -torch>=1.7.0 -numpy>=1.19.0 -Pillow>=8.0.0 -freetype-py>=2.5.1 +torch +opencv-python +numpy +Pillow>=10.0.0 +freetype-py>=2.5.1 +pycairo>=1.29.0 +scipy>=1.10.0 diff --git a/web/rs_overlay_pro.js b/web/rs_overlay_pro.js new file mode 100644 index 0000000..b941175 --- /dev/null +++ b/web/rs_overlay_pro.js @@ -0,0 +1,2567 @@ +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +let activeNodeId = null; + +const HEAVY_PARAMS = ['text', 'font_name', 'outline_thickness', 'effect_mode', 'text_scale_x', 'text_scale_y', 'text_align', 'line_spacing', 'letter_spacing', 'enable_glow', 'glow_color', 'glow_size', 'glow_spread', 'glow_opacity']; +const GLOW_PARAMS = ['enable_glow', 'glow_color', 'glow_size', 'glow_spread', 'glow_opacity']; +const SHADOW_PARAMS = ['enable_shadow', 'shadow_color', 'shadow_offset_x', 'shadow_offset_y', 'shadow_opacity', 'shadow_blur']; + +// Дефолтные значения для эффектов +const DEFAULT_EFFECTS = { + outline: { + outline_thickness: 0, + outline_color: "#808080", + outline_opacity: 1.0 + }, + glow: { + enable_glow: false, + glow_color: "#FFFFFF", + glow_size: 100, + glow_spread: 150, + glow_opacity: 1.0 + }, + shadow: { + enable_shadow: false, + shadow_color: "#333333", + shadow_offset_x: 10, + shadow_offset_y: 10, + shadow_opacity: 0.8, + shadow_blur: 15 + } +}; + +app.registerExtension({ + name: "RaykoStudio.RSOverlayPro", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "RS_OverlayPro") return; + + function createColorPickerRow(node, labelText, paramName, defaultColor) { + const container = document.createElement('div'); + container.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;'; + const label = document.createElement('div'); + label.textContent = labelText; + label.style.cssText = 'flex:6;font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:0.5px;'; + const hexInput = document.createElement('input'); + hexInput.type = 'text'; + hexInput.value = defaultColor; + hexInput.style.cssText = 'flex:3.5;padding:4px 6px;background:#1a1a1a;color:#fff;border:1px solid #444;border-radius:4px;font-family:monospace;font-size:11px;outline:none;transition:0.15s;'; + hexInput.onmouseenter = () => { hexInput.style.borderColor = '#666'; }; + hexInput.onmouseleave = () => { if (document.activeElement !== hexInput) hexInput.style.borderColor = '#444'; }; + hexInput.onfocus = () => { hexInput.style.borderColor = '#4CAF50'; }; + hexInput.onblur = () => { hexInput.style.borderColor = '#444'; validateAndUpdate(); }; + hexInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateAndUpdate(); hexInput.blur(); } }; + const colorBox = document.createElement('div'); + colorBox.style.cssText = 'flex:0 0 24px;width:24px;height:24px;background:' + defaultColor + ';border:1px solid #444;border-radius:4px;position:relative;cursor:pointer;transition:0.15s;'; + colorBox.onmouseenter = () => { colorBox.style.borderColor = '#888'; }; + colorBox.onmouseleave = () => { colorBox.style.borderColor = '#444'; }; + const nativePicker = document.createElement('input'); + nativePicker.type = 'color'; + nativePicker.value = defaultColor; + nativePicker.style.cssText = 'opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer;'; + nativePicker.oninput = (e) => { + const color = e.target.value.toUpperCase(); + hexInput.value = color; + colorBox.style.background = color; + node.textParams[paramName] = color; + if (typeof node.scheduleRender === 'function') node.scheduleRender(paramName); + node.syncAllWidgets(); + }; + colorBox.appendChild(nativePicker); + function validateAndUpdate() { + let value = hexInput.value.trim(); + if (!value.startsWith('#')) value = '#' + value; + const hexRegex = /^#([0-9A-Fa-f]{3}){1,2}$/; + if (hexRegex.test(value)) { + if (value.length === 4) value = '#' + value[1] + value[1] + value[2] + value[2] + value[3] + value[3]; + value = value.toUpperCase(); + hexInput.value = value; + colorBox.style.background = value; + nativePicker.value = value; + node.textParams[paramName] = value; + if (typeof node.scheduleRender === 'function') node.scheduleRender(paramName); + node.syncAllWidgets(); + } else { + hexInput.value = node.textParams[paramName] || defaultColor; + } + } + container.appendChild(label); + container.appendChild(hexInput); + container.appendChild(colorBox); + if (!node.colorHexInputs) node.colorHexInputs = {}; + if (!node.colorBoxes) node.colorBoxes = {}; + node.colorHexInputs[paramName] = hexInput; + node.colorBoxes[paramName] = colorBox; + return container; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + if (onNodeCreated) onNodeCreated.apply(this, arguments); + + this.overlay = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; + this.overlayRelative = { x: 0.5, y: 0.5, width: 0.3, height: 0.3, rotation: 0 }; + this.realOverlay = { width: 0, height: 0 }; + this.realBackground = { width: 0, height: 0 }; + this.displayWidth = 420; + this.displayHeight = 420; + this.canvasPixelSize = 420; + this.viewScale = 1.0; + this.viewOffsetX = 0; + this.viewOffsetY = 0; + this.backgroundImage = null; + this.isEditing = false; + this.isLoading = false; + this.dragType = null; + this.dragState = null; + this.currentSessionTimestamp = null; + this.isDragging = false; + this.awaitingServerRender = false; + this.debug = false; + this.textMask = null; + this.outlineMask = null; + this._offCanvas = null; + this._offCtx = null; + this.baseWidth = 0; + this.baseHeight = 0; + this._glowMask = null; + this._textMaskFile = null; + this._glowMaskTimeout = null; + this._glowMaskTimestamp = null; + this._glowExtraPadding = 0; + this.glowPaddingPreview = 0; + this._glowMaskAbortController = null; + this._glowKey = null; + this._glowDrawLogged = false; + this._glowFallbackLogged = false; + this._shadowMask = null; + this._shadowMaskTimeout = null; + this._shadowMaskTimestamp = null; + this._shadowMaskAbortController = null; + this._shadowKey = null; + this.colorHexInputs = {}; + this.colorBoxes = {}; + this._updatingWidgets = false; + this._sectionStates = { text: true, outline: false, glow: false, shadow: false }; + + this.textParams = { + text: "", + font_name: "", + text_color: "#FFFFFF", + outline_thickness: 0, + outline_color: "#808080", + text_opacity: 1.0, + outline_opacity: 1.0, + effect_mode: "on", + text_scale_x: 1.0, + text_scale_y: 1.0, + min_font_size: 4, + max_font_size: 2000, + padding: 5, + padding_y: 10, + text_align: "center", + line_spacing: 1.0, + letter_spacing: 0.0, + enable_glow: false, + glow_color: "#FFFFFF", + glow_size: 100, + glow_spread: 150, + glow_opacity: 1.0, + enable_shadow: false, + shadow_color: "#333333", + shadow_offset_x: 10, + shadow_offset_y: 10, + shadow_opacity: 0.8, + shadow_blur: 15 + }; + + this.fontList = []; + this.advancedMode = false; + this.fontMenu = null; + + (async () => { + try { + const response = await api.fetchApi("/rayko/rs_overlay_pro/get_fonts"); + const data = await response.json(); + if (data.font_list && data.font_list.length > 0) { + this.fontList = data.font_list; + if (!this.textParams.font_name || this.textParams.font_name === "") { + this.textParams.font_name = data.default_font || this.fontList[0]; + } + if (this.overlayInputs && this.overlayInputs.font_name) { + this.overlayInputs.font_name.innerHTML = ''; + this.fontList.forEach(f => { + const op = document.createElement('option'); + op.value = f; + op.textContent = f; + if (f === this.textParams.font_name) op.selected = true; + this.overlayInputs.font_name.appendChild(op); + }); + } + this.setDirtyCanvas(true); + } + } catch(e) { + console.error("Failed to load font list:", e); + } + })(); + + this.overlayContainer = null; + this.overlayCanvas = null; + this.overlayCtx = null; + this.overlayRenderLoop = null; + this.overlayInputs = {}; + this.sliderDisplays = {}; + this.currentRenderAbortController = null; + this.canvasRealWidth = 0; + this.canvasRealHeight = 0; + this.minWidth = 500; + this.minHeight = 420; + this.setSize([this.minWidth, this.minHeight]); + + this.btnApplyHover = false; + this.btnCancelHover = false; + this.textareaState = { isOpen: false, element: null }; + this.textareaRect = null; + this.textareaHover = false; + this.lastClickCoords = null; + this.fontSelectRect = null; + this.hexRect = null; + this.colorBoxRect = null; + this.fontSelectHover = false; + this.hexHover = false; + this.colorBoxHover = false; + this.alignButtonsRects = []; + this.alignButtonsHover = [false, false, false]; + this.lineSpacingRect = null; + this.lineSpacingHover = false; + this.lineSpacingDragging = false; + this.lineSpacingPopup = null; + this.lineSpacingValueRect = null; + this.lineSpacingValueHover = false; + this.lineSpacingTrackRect = null; + this.fontColorY = 0; + this.buttonsY = 0; + this.pendingEditorData = null; + this.renderTimeout = null; + + this.syncAllWidgets = function() { + if (this._updatingWidgets) return; + this._updatingWidgets = true; + try { + if (this.overlayInputs.text) this.overlayInputs.text.value = this.textParams.text || ''; + if (this.overlayInputs.font_name) this.overlayInputs.font_name.value = this.textParams.font_name || ''; + for (const key in this.overlayInputs) { + const el = this.overlayInputs[key]; + if (!el) continue; + if (el.type === 'range') { + el.value = this.textParams[key]; + if (this.sliderDisplays[key]) { + const step = parseFloat(el.step) || 1; + this.sliderDisplays[key].textContent = step < 1 ? parseFloat(this.textParams[key]).toFixed(2) : String(parseInt(this.textParams[key])); + } + } else if (el.type === 'checkbox') { + el.checked = !!this.textParams[key]; + } + } + if (this.alignButtons) { + const align = this.textParams.text_align || 'center'; + this.alignButtons.forEach((btn, i) => { + const types = ['left', 'center', 'right']; + const isActive = types[i] === align; + if (isActive) { + btn.style.background = '#4CAF50'; + btn.style.color = '#fff'; + btn.style.borderColor = '#4CAF50'; + } else { + btn.style.background = '#252525'; + btn.style.color = '#aaa'; + btn.style.borderColor = '#444'; + } + }); + } + if (this.colorHexInputs) { + for (const key in this.colorHexInputs) { + const hexInput = this.colorHexInputs[key]; + const colorBox = this.colorBoxes[key]; + if (hexInput && this.textParams[key] !== undefined) { + hexInput.value = this.textParams[key]; + if (colorBox) colorBox.style.background = this.textParams[key]; + } + } + } + } finally { + this._updatingWidgets = false; + } + }; + + if (!this.overlayContainer) { + this.overlayContainer = document.createElement('div'); + this.overlayContainer.style.cssText = 'position:fixed;top:60px;left:0;right:0;bottom:0;background:rgba(10,10,10,0.96);z-index:999;display:none;flex-direction:row;align-items:stretch;font-family:system-ui,-apple-system,sans-serif;'; + + this.overlayCanvasWrapper = document.createElement('div'); + this.overlayCanvasWrapper.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;'; + + this.overlayCanvas = document.createElement('canvas'); + this.overlayCanvas.style.cssText = 'box-shadow:0 8px 32px rgba(0,0,0,0.7);cursor:crosshair;max-width:98%;max-height:98%;border-radius:8px;'; + this.overlayCanvasWrapper.appendChild(this.overlayCanvas); + this.overlayCtx = this.overlayCanvas.getContext('2d'); + + this.sidePanel = document.createElement('div'); + this.sidePanel.style.cssText = 'width:320px;background:#151515;border-left:1px solid #333;padding:12px;display:flex;flex-direction:column;gap:4px;box-sizing:border-box;overflow-y:auto;'; + + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = 'position:sticky;top:0;background:#151515;z-index:10;padding:0 0 8px 0;border-bottom:1px solid #333;margin-bottom:8px;'; + this.sidePanel.appendChild(buttonContainer); + + const makeLabel = (txt) => { + const l = document.createElement('label'); + l.textContent = txt; + l.style.cssText = 'color:#aaa;font-size:11px;font-weight:600;margin-bottom:-4px;display:block;'; + return l; + }; + + const makeSectionHeader = (label, effectName, isEnabled, onToggle, onExpand) => { + const header = document.createElement('div'); + header.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;'; + + const chevron = document.createElement('span'); + chevron.textContent = '▶'; + chevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;'; + + const lbl = document.createElement('label'); + lbl.textContent = label; + lbl.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`; + lbl.style.color = isEnabled ? '#00FF00' : '#666'; + + header.appendChild(chevron); + header.appendChild(lbl); + + if (effectName) { + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = isEnabled; + toggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;'; + toggle.addEventListener('change', (e) => { + e.stopPropagation(); + onToggle(toggle.checked); + }); + header.appendChild(toggle); + } + + header.addEventListener('click', (e) => { + if (e.target.type !== 'checkbox') { + onExpand(); + } + }); + + return { header, chevron, lbl }; + }; + + const makeSelect = (opts, val) => { + const s = document.createElement('select'); + opts.forEach(o => { + const op = document.createElement('option'); + op.value = o; + op.textContent = o; + if (o === val) op.selected = true; + s.appendChild(op); + }); + s.style.cssText = 'width:100%;background:#252525;color:#eee;border:1px solid #444;border-radius:4px;padding:6px;font-size:12px;outline:none;'; + return s; + }; + + const makeTextarea = (value) => { + const t = document.createElement('textarea'); + t.value = value; + t.style.cssText = 'width:100%;background:#252525;color:#eee;border:1px solid #444;border-radius:4px;padding:6px;font-size:12px;outline:none;resize:vertical;min-height:60px;'; + return t; + }; + + const makeBtn = (txt, col, onClick) => { + const b = document.createElement('button'); + b.textContent = txt; + b.style.cssText = `width:100%;padding:10px;background:#222;color:${col};border:1px solid ${col};border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;margin-top:4px;transition:0.15s;`; + b.onmouseenter = () => { b.style.background = '#2a2a2a'; b.style.transform = 'translateY(-1px)'; }; + b.onmouseleave = () => { b.style.background = '#222'; b.style.transform = 'none'; }; + b.onclick = (e) => { e.stopPropagation(); onClick(); }; + return b; + }; + + const div = () => { const d = document.createElement('div'); d.style.cssText = 'height:1px;background:#333;margin:4px 0;'; return d; }; + + const formatValue = (val, step) => { + if (step < 1) return parseFloat(val).toFixed(2); + return String(parseInt(val)); + }; + + const makeToggle = (label, key, currentValue) => { + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex;align-items:center;gap:8px;'; + const lbl = document.createElement('label'); + lbl.textContent = label; + lbl.style.cssText = 'flex:1;color:#aaa;font-size:11px;font-weight:600;'; + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = currentValue; + toggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;'; + toggle.addEventListener('change', () => { + this.textParams[key] = toggle.checked; + scheduleRender(key); + this.syncAllWidgets(); + }); + wrap.appendChild(lbl); + wrap.appendChild(toggle); + this.overlayInputs[key] = toggle; + return wrap; + }; + + const scheduleRender = (key, immediate = false) => { + if (SHADOW_PARAMS.includes(key)) { + this.setDirtyCanvas(true); + this.syncAllWidgets(); + this.requestShadowMask(); + return; + } + const isHeavy = HEAVY_PARAMS.includes(key); + const isGlowParam = GLOW_PARAMS.includes(key); + if (isHeavy && !isGlowParam) { + if (this.currentRenderAbortController) { + this.currentRenderAbortController.abort(); + this.currentRenderAbortController = null; + } + if (this.renderTimeout) clearTimeout(this.renderTimeout); + const delay = immediate ? 0 : 40; + this.renderTimeout = setTimeout(() => { + this.currentRenderAbortController = new AbortController(); + const signal = this.currentRenderAbortController.signal; + this.renderMasks(signal); + }, delay); + } else if (isGlowParam) { + this.requestGlowMask(); + } else { + this.setDirtyCanvas(true); + } + this.syncAllWidgets(); + }; + this.scheduleRender = scheduleRender; + + const makeSlider = (label, key, min, max, step, isFloat = false) => { + const container = document.createElement('div'); + container.style.cssText = 'display:flex;flex-direction:column;gap:4px;'; + const lbl = document.createElement('label'); + lbl.textContent = label; + lbl.style.cssText = 'color:#aaa;font-size:11px;font-weight:600;'; + container.appendChild(lbl); + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:8px;'; + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = min; + slider.max = max; + slider.step = step; + slider.value = this.textParams[key]; + slider.style.cssText = 'flex:1;height:4px;background:#252525;border-radius:2px;outline:none;cursor:pointer;-webkit-appearance:none;'; + const valueDisplay = document.createElement('div'); + valueDisplay.textContent = formatValue(this.textParams[key], step); + valueDisplay.style.cssText = 'min-width:50px;text-align:center;background:#252525;color:#4CAF50;border:1px solid #444;border-radius:4px;padding:4px 8px;font-size:12px;cursor:pointer;font-weight:600;transition:0.15s;'; + valueDisplay.onmouseenter = () => { valueDisplay.style.background = '#2a2a2a'; valueDisplay.style.borderColor = '#4CAF50'; }; + valueDisplay.onmouseleave = () => { valueDisplay.style.background = '#252525'; valueDisplay.style.borderColor = '#444'; }; + valueDisplay.onclick = (e) => { + e.stopPropagation(); + const currentValue = this.textParams[key]; + 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;'; + const input = document.createElement('input'); + input.type = 'number'; + input.value = currentValue; + input.min = min; + input.max = max; + input.step = step; + 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.style.cssText = 'background:#4CAF50;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;'; + const doSave = () => { + let num = isFloat ? parseFloat(input.value) : parseInt(input.value); + if (isNaN(num)) num = currentValue; + num = Math.max(min, Math.min(max, num)); + this.textParams[key] = num; + slider.value = num; + valueDisplay.textContent = formatValue(num, step); + popup.remove(); + scheduleRender(key); + this.syncAllWidgets(); + }; + saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); }; + input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } }; + popup.appendChild(input); + popup.appendChild(saveBtn); + document.body.appendChild(popup); + const popupWidth = popup.offsetWidth; + const popupHeight = popup.offsetHeight; + let leftPos = e.clientX - popupWidth - 8; + let topPos = e.clientY; + if (leftPos < 8) leftPos = e.clientX + 8; + if (topPos + popupHeight > window.innerHeight - 8) topPos = window.innerHeight - popupHeight - 8; + popup.style.left = leftPos + 'px'; + popup.style.top = topPos + 'px'; + setTimeout(() => { input.focus(); input.select(); }, 50); + setTimeout(() => { + const closeHandler = (ev) => { + if (!popup.contains(ev.target)) { + popup.remove(); + document.removeEventListener('mousedown', closeHandler); + } + }; + document.addEventListener('mousedown', closeHandler); + }, 100); + }; + slider.oninput = () => { + const val = isFloat ? parseFloat(slider.value) : parseInt(slider.value); + this.textParams[key] = val; + valueDisplay.textContent = formatValue(val, step); + scheduleRender(key); + this.syncAllWidgets(); + }; + this.overlayInputs[key] = slider; + this.sliderDisplays[key] = valueDisplay; + row.appendChild(slider); + row.appendChild(valueDisplay); + container.appendChild(row); + return container; + }; + + const btnNormalMode = makeBtn("🟢 NORMAL MODE", "#2196F3", () => { this._toggleAdvancedMode(); }); + buttonContainer.appendChild(btnNormalMode); + const btnApply = makeBtn("✔️ APPLY", "#4CAF50", () => { this.sendTransforms(); this._toggleAdvancedMode(); }); + buttonContainer.appendChild(btnApply); + const btnCancel = makeBtn("❌ CANCEL", "#dc3545", () => { this.cancelEditing(); this._toggleAdvancedMode(); }); + buttonContainer.appendChild(btnCancel); + + // TEXT секция (всегда раскрыта, без чекбокса) + const textHeader = document.createElement('div'); + textHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;'; + const textChevron = document.createElement('span'); + textChevron.textContent = '▼'; + textChevron.style.cssText = 'color:#666;font-size:12px;'; + const textLabel = document.createElement('label'); + textLabel.textContent = 'TEXT'; + textLabel.style.cssText = 'flex:1;color:#aaa;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;'; + textHeader.appendChild(textChevron); + textHeader.appendChild(textLabel); + this.sidePanel.appendChild(textHeader); + + const textContent = document.createElement('div'); + textContent.style.cssText = 'display:block;'; + + this.overlayInputs.text = makeTextarea(this.textParams.text); + this.overlayInputs.text.addEventListener('input', () => { + this.textParams.text = this.overlayInputs.text.value; + scheduleRender('text'); + this.syncAllWidgets(); + }); + textContent.appendChild(this.overlayInputs.text); + + const alignContainer = document.createElement('div'); + alignContainer.style.cssText = 'display:flex;gap:8px;margin-top:8px;'; + const alignTypes = ['left', 'center', 'right']; + const alignIcons = [ + '', + '', + '' + ]; + this.alignButtons = []; + alignTypes.forEach((type, i) => { + const btn = document.createElement('button'); + btn.innerHTML = alignIcons[i]; + btn.style.cssText = `flex:1;padding:4px;background:#252525;color:#aaa;border:1px solid #444;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:0.15s;`; + btn.onmouseenter = () => { btn.style.background = '#2a2a2a'; btn.style.color = '#fff'; }; + btn.onmouseleave = () => { if (this.textParams.text_align !== type) { btn.style.background = '#252525'; btn.style.color = '#aaa'; } }; + btn.onclick = (e) => { + e.stopPropagation(); + this.textParams.text_align = type; + this.alignButtons.forEach((b, j) => { + if (j === i) { + b.style.background = '#4CAF50'; + b.style.color = '#fff'; + b.style.borderColor = '#4CAF50'; + } else { + b.style.background = '#252525'; + b.style.color = '#aaa'; + b.style.borderColor = '#444'; + } + }); + scheduleRender('text_align'); + this.syncAllWidgets(); + }; + if (this.textParams.text_align === type) { + btn.style.background = '#4CAF50'; + btn.style.color = '#fff'; + btn.style.borderColor = '#4CAF50'; + } + this.alignButtons.push(btn); + alignContainer.appendChild(btn); + }); + textContent.appendChild(alignContainer); + textContent.appendChild(div()); + + this.overlayInputs.font_name = makeSelect([], ""); + this.overlayInputs.font_name.addEventListener('change', () => { + this.textParams.font_name = this.overlayInputs.font_name.value; + scheduleRender('font_name'); + this.syncAllWidgets(); + }); + textContent.appendChild(this.overlayInputs.font_name); + textContent.appendChild(div()); + + textContent.appendChild(createColorPickerRow(this, "TEXT COLOR", "text_color", this.textParams.text_color)); + textContent.appendChild(div()); + textContent.appendChild(makeSlider("TEXT OPACITY", "text_opacity", 0, 1, 0.05, true)); + textContent.appendChild(makeSlider("LINE SPACING", "line_spacing", 0.5, 3, 0.1, true)); + textContent.appendChild(makeSlider("LETTER SPACING", "letter_spacing", -20, 100, 0.5, true)); + textContent.appendChild(div()); + + this.sidePanel.appendChild(textContent); + + // OUTLINE секция (без чекбокса, включается через thickness) + const outlineHeader = document.createElement('div'); + outlineHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;'; + const outlineChevron = document.createElement('span'); + outlineChevron.textContent = this._sectionStates.outline ? '▼' : '▶'; + outlineChevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;'; + const outlineLabel = document.createElement('label'); + outlineLabel.textContent = 'OUTLINE SETTINGS'; + outlineLabel.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`; + outlineLabel.style.color = this.textParams.outline_thickness > 0 ? '#00FF00' : '#666'; + outlineHeader.appendChild(outlineChevron); + outlineHeader.appendChild(outlineLabel); + outlineHeader.addEventListener('click', () => { + this._sectionStates.outline = !this._sectionStates.outline; + outlineChevron.textContent = this._sectionStates.outline ? '▼' : '▶'; + outlineContent.style.display = this._sectionStates.outline ? 'block' : 'none'; + }); + this.sidePanel.appendChild(outlineHeader); + + const outlineContent = document.createElement('div'); + outlineContent.style.cssText = this._sectionStates.outline ? 'display:block;' : 'display:none;'; + outlineContent.appendChild(div()); + outlineContent.appendChild(makeSlider("THICKNESS", "outline_thickness", 0, 50, 1, false)); + outlineContent.appendChild(createColorPickerRow(this, "COLOR", "outline_color", this.textParams.outline_color)); + outlineContent.appendChild(div()); + outlineContent.appendChild(makeSlider("OPACITY", "outline_opacity", 0, 1, 0.05, true)); + outlineContent.appendChild(div()); + this.sidePanel.appendChild(outlineContent); + + // GLOW секция (с чекбоксом) + const glowHeader = document.createElement('div'); + glowHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;'; + const glowChevron = document.createElement('span'); + glowChevron.textContent = this._sectionStates.glow ? '▼' : '▶'; + glowChevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;'; + const glowLabel = document.createElement('label'); + glowLabel.textContent = 'GLOW SETTINGS'; + glowLabel.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`; + glowLabel.style.color = this.textParams.enable_glow ? '#00FF00' : '#666'; + glowHeader.appendChild(glowChevron); + glowHeader.appendChild(glowLabel); + + const glowToggle = document.createElement('input'); + glowToggle.type = 'checkbox'; + glowToggle.checked = this.textParams.enable_glow; + glowToggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;'; + glowToggle.addEventListener('click', (e) => e.stopPropagation()); + glowToggle.addEventListener('change', (e) => { + e.stopPropagation(); + const enabled = e.target.checked; + this.textParams.enable_glow = enabled; + + if (!enabled) { + // Сброс в дефолт + this.textParams.glow_color = DEFAULT_EFFECTS.glow.glow_color; + this.textParams.glow_size = DEFAULT_EFFECTS.glow.glow_size; + this.textParams.glow_spread = DEFAULT_EFFECTS.glow.glow_spread; + this.textParams.glow_opacity = DEFAULT_EFFECTS.glow.glow_opacity; + } + + glowLabel.style.color = enabled ? '#00FF00' : '#666'; + this.syncAllWidgets(); + scheduleRender('enable_glow'); + }); + glowHeader.appendChild(glowToggle); + + glowHeader.addEventListener('click', (e) => { + if (e.target.type !== 'checkbox') { + this._sectionStates.glow = !this._sectionStates.glow; + glowChevron.textContent = this._sectionStates.glow ? '▼' : '▶'; + glowContent.style.display = this._sectionStates.glow ? 'block' : 'none'; + } + }); + this.sidePanel.appendChild(glowHeader); + + const glowContent = document.createElement('div'); + glowContent.style.cssText = this._sectionStates.glow ? 'display:block;' : 'display:none;'; + glowContent.appendChild(div()); + glowContent.appendChild(createColorPickerRow(this, "GLOW COLOR", "glow_color", this.textParams.glow_color)); + glowContent.appendChild(div()); + glowContent.appendChild(makeSlider("SIZE", "glow_size", 0, 200, 1, false)); + glowContent.appendChild(makeSlider("SPREAD", "glow_spread", 0, 300, 1, false)); + glowContent.appendChild(makeSlider("OPACITY", "glow_opacity", 0, 1, 0.02, true)); + glowContent.appendChild(div()); + this.sidePanel.appendChild(glowContent); + + // SHADOW секция (с чекбоксом) + const shadowHeader = document.createElement('div'); + shadowHeader.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer;user-select:none;'; + const shadowChevron = document.createElement('span'); + shadowChevron.textContent = this._sectionStates.shadow ? '▼' : '▶'; + shadowChevron.style.cssText = 'color:#666;font-size:12px;transition:transform 200ms;flex-shrink:0;'; + const shadowLabel = document.createElement('label'); + shadowLabel.textContent = 'SHADOW SETTINGS'; + shadowLabel.style.cssText = `flex:1;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;transition:color 200ms;`; + shadowLabel.style.color = this.textParams.enable_shadow ? '#00FF00' : '#666'; + shadowHeader.appendChild(shadowChevron); + shadowHeader.appendChild(shadowLabel); + + const shadowToggle = document.createElement('input'); + shadowToggle.type = 'checkbox'; + shadowToggle.checked = this.textParams.enable_shadow; + shadowToggle.style.cssText = 'width:40px;height:20px;cursor:pointer;accent-color:#4CAF50;'; + shadowToggle.addEventListener('click', (e) => e.stopPropagation()); + shadowToggle.addEventListener('change', (e) => { + e.stopPropagation(); + const enabled = e.target.checked; + this.textParams.enable_shadow = enabled; + + if (!enabled) { + // Сброс в дефолт + this.textParams.shadow_color = DEFAULT_EFFECTS.shadow.shadow_color; + this.textParams.shadow_offset_x = DEFAULT_EFFECTS.shadow.shadow_offset_x; + this.textParams.shadow_offset_y = DEFAULT_EFFECTS.shadow.shadow_offset_y; + this.textParams.shadow_opacity = DEFAULT_EFFECTS.shadow.shadow_opacity; + this.textParams.shadow_blur = DEFAULT_EFFECTS.shadow.shadow_blur; + } + + shadowLabel.style.color = enabled ? '#00FF00' : '#666'; + this.syncAllWidgets(); + scheduleRender('enable_shadow'); + }); + shadowHeader.appendChild(shadowToggle); + + shadowHeader.addEventListener('click', (e) => { + if (e.target.type !== 'checkbox') { + this._sectionStates.shadow = !this._sectionStates.shadow; + shadowChevron.textContent = this._sectionStates.shadow ? '▼' : '▶'; + shadowContent.style.display = this._sectionStates.shadow ? 'block' : 'none'; + } + }); + this.sidePanel.appendChild(shadowHeader); + + const shadowContent = document.createElement('div'); + shadowContent.style.cssText = this._sectionStates.shadow ? 'display:block;' : 'display:none;'; + shadowContent.appendChild(div()); + shadowContent.appendChild(createColorPickerRow(this, "COLOR", "shadow_color", this.textParams. shadow_color)); + shadowContent.appendChild(div()); + shadowContent.appendChild(makeSlider("OFFSET X", "shadow_offset_x", -30, 30, 1, false)); + shadowContent.appendChild(makeSlider("OFFSET Y", "shadow_offset_y", -30, 30, 1, false)); + shadowContent.appendChild(makeSlider("BLUR", "shadow_blur", 0, 100, 1, false)); + shadowContent.appendChild(makeSlider("OPACITY", "shadow_opacity", 0, 1, 0.05, true)); + shadowContent.appendChild(div()); + this.sidePanel.appendChild(shadowContent); + + this.overlayContainer.appendChild(this.overlayCanvasWrapper); + this.overlayContainer.appendChild(this.sidePanel); + document.body.appendChild(this.overlayContainer); + + this.syncAllWidgets(); + } + + api.addEventListener("rs-overlay-pro-start", (event) => { + if (event.detail.id != this.id) return; + this.pendingEditorData = event.detail; + this.openDeferredEditor(); + }); + + api.addEventListener("interrupted", () => { + if (this.currentRenderAbortController) { + this.currentRenderAbortController.abort(); + this.currentRenderAbortController = null; + } + if (this._glowMaskTimeout) { + clearTimeout(this._glowMaskTimeout); + this._glowMaskTimeout = null; + } + if (this._glowMaskAbortController) { + this._glowMaskAbortController.abort(); + this._glowMaskAbortController = null; + } + if (this._shadowMaskTimeout) { + clearTimeout(this._shadowMaskTimeout); + this._shadowMaskTimeout = null; + } + if (this._shadowMaskAbortController) { + this._shadowMaskAbortController.abort(); + this._shadowMaskAbortController = null; + } + this.pendingEditorData = null; + this.isLoading = false; + this.isEditing = false; + this.dragType = null; + this.dragState = null; + this.awaitingServerRender = false; + this._glowMask = null; + this._textMaskFile = null; + this._glowKey = null; + this._shadowMask = null; + this._shadowKey = null; + this._sectionStates = { text: true, outline: false, glow: false, shadow: false }; + this.setDirtyCanvas(true); + this.closeTextarea(); + }); + }; + + nodeType.prototype.requestGlowMask = function() { + if (!this.textParams.enable_glow) { + this._glowMask = null; + this._glowExtraPadding = 0; + this.glowPaddingPreview = 0; + this._glowKey = null; + this.setDirtyCanvas(true); + return; + } + if (!this.textMask || !this._textMaskFile) return; + const glowSize = this.textParams.glow_size; + const glowSpread = this.textParams.glow_spread; + if (glowSize <= 0) { + this._glowMask = null; + this._glowExtraPadding = 0; + this.glowPaddingPreview = 0; + this._glowKey = null; + this.setDirtyCanvas(true); + return; + } + const glowKey = `${this._textMaskFile}_${glowSize}_${glowSpread}_${this.textParams.glow_opacity}_${this.textParams.glow_color}`; + if (this._glowKey === glowKey && this._glowMask) return; + this._glowKey = glowKey; + const glowPadding = glowSize * (this.displayWidth / this.canvasRealWidth); + this.glowPaddingPreview = glowPadding; + clearTimeout(this._glowMaskTimeout); + this._glowMaskTimeout = setTimeout(() => { + this.fetchGlowMaskFromServer(); + }, 500); + }; + + nodeType.prototype.fetchGlowMaskFromServer = async function() { + try { + if (this._glowMaskAbortController) this._glowMaskAbortController.abort(); + this._glowMaskAbortController = new AbortController(); + const payload = { + text_mask_file: this._textMaskFile, + glow_size: this.textParams.glow_size, + glow_spread: this.textParams.glow_spread, + glow_opacity: this.textParams.glow_opacity, + node_id: String(this.id) + }; + const response = await api.fetchApi('/rayko/rs_overlay_pro/render_glow_mask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: this._glowMaskAbortController.signal + }); + const data = await response.json(); + if (data.glow_file) { + const img = new Image(); + img.crossOrigin = "Anonymous"; + const ts = data.timestamp; + this._glowMaskTimestamp = ts; + this._glowExtraPadding = data.extra_padding || 0; + img.onload = () => { + if (this._glowMaskTimestamp === ts) { + this._glowMask = img; + this._glowDrawLogged = false; + this._glowFallbackLogged = false; + this.setDirtyCanvas(true); + } + }; + img.onerror = () => {}; + img.src = `/view?filename=${data.glow_file}&type=temp&t=${ts}`; + } + } catch (e) { + if (e.name === 'AbortError') return; + } + }; + + nodeType.prototype.requestShadowMask = function() { + if (!this.textParams.enable_shadow) { + this._shadowMask = null; + this._shadowKey = null; + this.setDirtyCanvas(true); + return; + } + if (!this.textMask || !this._textMaskFile) return; + const shadowBlur = this.textParams.shadow_blur || 0; + if (shadowBlur <= 0) { + this._shadowMask = null; + this._shadowKey = null; + this.setDirtyCanvas(true); + return; + } + const shadowKey = `${this._textMaskFile}_${shadowBlur}`; + if (this._shadowKey === shadowKey && this._shadowMask) return; + this._shadowKey = shadowKey; + clearTimeout(this._shadowMaskTimeout); + this._shadowMaskTimeout = setTimeout(() => { + this.fetchShadowMaskFromServer(); + }, 500); + }; + + nodeType.prototype.fetchShadowMaskFromServer = async function() { + try { + if (this._shadowMaskAbortController) this._shadowMaskAbortController.abort(); + this._shadowMaskAbortController = new AbortController(); + const payload = { + text_mask_file: this._textMaskFile, + shadow_blur: this.textParams.shadow_blur || 0, + node_id: String(this.id) + }; + const response = await api.fetchApi('/rayko/rs_overlay_pro/render_shadow_mask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: this._shadowMaskAbortController.signal + }); + const data = await response.json(); + if (data.shadow_file) { + const img = new Image(); + img.crossOrigin = "Anonymous"; + const ts = data.timestamp; + this._shadowMaskTimestamp = ts; + img.onload = () => { + if (this._shadowMaskTimestamp === ts) { + this._shadowMask = img; + this.setDirtyCanvas(true); + } + }; + img.onerror = () => {}; + img.src = `/view?filename=${data.shadow_file}&type=temp&t=${ts}`; + } + } catch (e) { + if (e.name === 'AbortError') return; + } + }; + + nodeType.prototype.drawMasked = function(ctx, maskImg, x, y, w, h, color, opacity) { + if (!maskImg) return; + if (!this._offCanvas) { + this._offCanvas = document.createElement('canvas'); + this._offCtx = this._offCanvas.getContext('2d'); + } + const off = this._offCanvas; + const offCtx = this._offCtx; + const mw = maskImg.naturalWidth || maskImg.width || w; + const mh = maskImg.naturalHeight || maskImg.height || h; + if (off.width !== mw || off.height !== mh) { + off.width = mw; + off.height = mh; + } + offCtx.clearRect(0, 0, mw, mh); + offCtx.drawImage(maskImg, 0, 0, mw, mh); + offCtx.globalCompositeOperation = 'source-in'; + offCtx.globalAlpha = Math.min(1, Math.max(0, opacity)); + offCtx.fillStyle = color; + offCtx.fillRect(0, 0, mw, mh); + offCtx.globalCompositeOperation = 'source-over'; + offCtx.globalAlpha = 1; + ctx.drawImage(off, x, y, w, h); + }; + + nodeType.prototype.drawGlowPreview = function(ctx, x, y, w, h) { + if (!this.textParams.enable_glow || !this.textMask) return; + const glowSize = this.textParams.glow_size; + if (glowSize <= 0) return; + const glowOpacity = this.textParams.glow_opacity; + const glowColor = this.textParams.glow_color; + if (this._glowMask) { + const padding = this._glowExtraPadding || 0; + if (!this._offCanvas) { + this._offCanvas = document.createElement('canvas'); + this._offCtx = this._offCanvas.getContext('2d'); + } + const off = this._offCanvas; + const offCtx = this._offCtx; + const mw = this._glowMask.naturalWidth; + const mh = this._glowMask.naturalHeight; + if (off.width !== mw || off.height !== mh) { + off.width = mw; + off.height = mh; + } + offCtx.clearRect(0, 0, mw, mh); + offCtx.drawImage(this._glowMask, 0, 0, mw, mh); + offCtx.globalCompositeOperation = 'source-in'; + offCtx.globalAlpha = Math.min(1, Math.max(0, glowOpacity)); + offCtx.fillStyle = glowColor; + offCtx.fillRect(0, 0, mw, mh); + offCtx.globalCompositeOperation = 'source-over'; + offCtx.globalAlpha = 1; + const textW = mw - 2 * padding; + const textH = mh - 2 * padding; + const scaleX = w / textW; + const scaleY = h / textH; + const glowW = mw * scaleX; + const glowH = mh * scaleY; + const glowX = x - padding * scaleX; + const glowY = y - padding * scaleY; + ctx.drawImage(off, glowX, glowY, glowW, glowH); + return; + } + const maxBlur = Math.min(250, glowSize); + ctx.save(); + ctx.shadowColor = glowColor; + ctx.shadowBlur = maxBlur; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + this.drawMasked(ctx, this.textMask, x, y, w, h, glowColor, glowOpacity); + ctx.restore(); + }; + + nodeType.prototype.drawShadow = function(ctx, x, y, w, h) { + if (!this.textParams.enable_shadow || !this.textMask) return; + const opacity = this.textParams.shadow_opacity; + if (opacity === undefined || opacity === null || opacity <= 0) return; + const offsetX = this.textParams.shadow_offset_x || 0; + const offsetY = this.textParams.shadow_offset_y || 0; + const color = this.textParams.shadow_color || '#333333'; + const blur = this.textParams.shadow_blur || 0; + + if (this._shadowMask && blur > 0) { + const mw = this._shadowMask.naturalWidth; + const mh = this._shadowMask.naturalHeight; + if (!this._offCanvas) { + this._offCanvas = document.createElement('canvas'); + this._offCtx = this._offCanvas.getContext('2d'); + } + const off = this._offCanvas; + const offCtx = this._offCtx; + if (off.width !== mw || off.height !== mh) { + off.width = mw; + off.height = mh; + } + offCtx.clearRect(0, 0, mw, mh); + offCtx.drawImage(this._shadowMask, 0, 0, mw, mh); + offCtx.globalCompositeOperation = 'source-in'; + offCtx.globalAlpha = Math.min(1, Math.max(0, opacity)); + offCtx.fillStyle = color; + offCtx.fillRect(0, 0, mw, mh); + offCtx.globalCompositeOperation = 'source-over'; + offCtx.globalAlpha = 1; + ctx.drawImage(off, x + offsetX, y + offsetY, w, h); + return; + } + + ctx.save(); + ctx.shadowColor = color; + ctx.shadowBlur = blur; + ctx.shadowOffsetX = offsetX; + ctx.shadowOffsetY = offsetY; + ctx.globalAlpha = opacity; + this.drawMasked(ctx, this.textMask, x, y, w, h, color, 1.0); + ctx.restore(); + }; + + nodeType.prototype.drawTextOverlay = function(ctx, x, y, w, h) { + this.drawGlowPreview(ctx, x, y, w, h); + this.drawShadow(ctx, x, y, w, h); + if (this.textParams.effect_mode === "on" && this.outlineMask) { + this.drawMasked(ctx, this.outlineMask, x, y, w, h, + this.textParams.outline_color, this.textParams.outline_opacity); + } + if (this.textMask) { + this.drawMasked(ctx, this.textMask, x, y, w, h, + this.textParams.text_color, this.textParams.text_opacity); + } + }; + + nodeType.prototype._resizeOverlayCanvas = function() { + if (!this.overlayCanvasWrapper || !this.overlayCanvas) return; + const w = this.overlayCanvasWrapper.clientWidth; + const h = this.overlayCanvasWrapper.clientHeight; + if (w > 0 && h > 0) { + this.overlayCanvas.width = w; + this.overlayCanvas.height = h; + if (this.isEditing && this.backgroundImage) { + this.updateOverlayAbsolute(); + this.computeAndApplyView(); + } + this.setDirtyCanvas(true); + if (this.advancedMode && this.isEditing) { + this.computeAndApplyView(); + } + } + }; + + nodeType.prototype._handleOverlayEvent = function(e, type) { + e.preventDefault(); + e.stopPropagation(); + const rect = this.overlayCanvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const scaleX = this.overlayCanvas.width / rect.width; + const scaleY = this.overlayCanvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + if (type === 'down') this.onMouseDown(null, [x, y]); + else if (type === 'move') this.onMouseMove(null, [x, y]); + else this.onMouseUp(); + this.setDirtyCanvas(true); + }; + + nodeType.prototype._handleOverlayWheel = function(e) { + if (!this.advancedMode || !this.isEditing || activeNodeId !== this.id) return; + e.preventDefault(); + e.stopPropagation(); + const canvasRect = this.overlayCanvas.getBoundingClientRect(); + if (canvasRect.width === 0 || canvasRect.height === 0) return; + const scaleX = this.overlayCanvas.width / canvasRect.width; + const scaleY = this.overlayCanvas.height / canvasRect.height; + const canvasX = (e.clientX - canvasRect.left) * scaleX; + const canvasY = (e.clientY - canvasRect.top) * scaleY; + const { rectX, rectY } = this.getCanvasMetrics(); + const worldX = (canvasX - rectX - this.viewOffsetX) / this.viewScale; + const worldY = (canvasY - rectY - this.viewOffsetY) / this.viewScale; + const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85; + this.viewScale = Math.max(0.05, Math.min(10.0, this.viewScale * zoomFactor)); + this.viewOffsetX = canvasX - rectX - (worldX * this.viewScale); + this.viewOffsetY = canvasY - rectY - (worldY * this.viewScale); + this.setDirtyCanvas(true); + }; + + nodeType.prototype._toggleAdvancedMode = function(forceClose = false) { + if (this.currentRenderAbortController) { + this.currentRenderAbortController.abort(); + this.currentRenderAbortController = null; + } + if (forceClose || this.advancedMode) { + this.updateRelativeFromAbsolute(); + this.advancedMode = false; + if (activeNodeId === this.id) activeNodeId = null; + this.overlayContainer.style.display = 'none'; + if (this._overlayWheelHandler) { + this.overlayCanvas.removeEventListener('wheel', this._overlayWheelHandler); + delete this._overlayWheelHandler; + } + if (this._overlayMouseHandler) { + this.overlayCanvas.removeEventListener('mousedown', this._overlayMouseHandler); + this.overlayCanvas.removeEventListener('mousemove', this._overlayMouseHandler); + this.overlayCanvas.removeEventListener('mouseup', this._overlayMouseHandler); + this.overlayCanvas.removeEventListener('mouseleave', this._overlayMouseLeaveHandler); + delete this._overlayMouseHandler; + delete this._overlayMouseLeaveHandler; + } + if (this._globalKeyHandler) window.removeEventListener('keydown', this._globalKeyHandler); + if (this.overlayRenderLoop) cancelAnimationFrame(this.overlayRenderLoop); + this.setDirtyCanvas(true); + return; + } + if (activeNodeId && activeNodeId !== this.id) { + const otherNode = app.graph?.nodes.find(n => n.id == activeNodeId); + if (otherNode && typeof otherNode._toggleAdvancedMode === 'function') { + otherNode._toggleAdvancedMode(true); + } + } + this.advancedMode = true; + activeNodeId = this.id; + this.overlayContainer.style.display = 'flex'; + requestAnimationFrame(() => { + this._resizeOverlayCanvas(); + this.computeAndApplyView(); + }); + this._overlayMouseHandler = (e) => this._handleOverlayEvent(e, e.type === 'mousedown' ? 'down' : e.type === 'mousemove' ? 'move' : 'up'); + this._overlayMouseLeaveHandler = () => this.onMouseUp(); + this.overlayCanvas.addEventListener('mousedown', this._overlayMouseHandler); + this.overlayCanvas.addEventListener('mousemove', this._overlayMouseHandler); + this.overlayCanvas.addEventListener('mouseup', this._overlayMouseHandler); + this.overlayCanvas.addEventListener('mouseleave', this._overlayMouseLeaveHandler); + this._overlayWheelHandler = (e) => this._handleOverlayWheel(e); + this.overlayCanvas.addEventListener('wheel', this._overlayWheelHandler, { passive: false }); + this._globalKeyHandler = (e) => { + if (e.key === 'Escape' && activeNodeId === this.id) { + this._toggleAdvancedMode(); + } + }; + window.addEventListener('keydown', this._globalKeyHandler); + let lastDrawTime = 0; + this.overlayRenderLoop = () => { + if (!this.advancedMode || !this.overlayCtx) return; + const now = performance.now(); + if (now - lastDrawTime < 50) { + requestAnimationFrame(this.overlayRenderLoop); + return; + } + lastDrawTime = now; + this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height); + this.drawOverlayCanvas(this.overlayCtx); + requestAnimationFrame(this.overlayRenderLoop); + }; + requestAnimationFrame(this.overlayRenderLoop); + }; + + nodeType.prototype.drawOverlayCanvas = function(ctx) { + if (!this.isEditing || !this.backgroundImage) { + ctx.fillStyle = "#888"; + ctx.font = "14px Arial"; + ctx.textAlign = "center"; + ctx.fillText("Loading...", this.overlayCanvas.width / 2, this.overlayCanvas.height / 2); + return; + } + const { rectX, rectY } = this.getCanvasMetrics(); + const useScale = this.viewScale; + const useOffsetX = this.viewOffsetX; + const useOffsetY = this.viewOffsetY; + ctx.save(); + ctx.translate(rectX + useOffsetX, rectY + useOffsetY); + ctx.scale(useScale, useScale); + ctx.drawImage(this.backgroundImage, -this.displayWidth / 2, -this.displayHeight / 2, this.displayWidth, this.displayHeight); + if (this.textMask) { + ctx.save(); + ctx.translate(this.overlay.x, this.overlay.y); + ctx.rotate(this.overlay.rotation * Math.PI / 180); + const rectW = this.overlay.width; + const rectH = this.overlay.height; + this.drawTextOverlay(ctx, -rectW / 2, -rectH / 2, rectW, rectH); + if (this.advancedMode) { + ctx.shadowColor = "rgba(0,0,0,0.8)"; + ctx.shadowBlur = 4 / useScale; + ctx.strokeStyle = "#00FF00"; + ctx.lineWidth = 1 / useScale; + ctx.strokeRect(-rectW / 2, -rectH / 2, rectW, rectH); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + const hw = rectW / 2, hh = rectH / 2; + const hs = 6 / useScale; + ctx.fillStyle = "#FF0000"; + [[hw, hh], [-hw, hh], [hw, -hh], [-hw, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs)); + [[hw, 0], [-hw, 0], [0, hh], [0, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs)); + const rotHandleY = -hh - 40; + ctx.beginPath(); + ctx.arc(0, rotHandleY, 7 / useScale, 0, Math.PI * 2); + ctx.fillStyle = "#ff9800"; + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1 / useScale; + ctx.stroke(); + } + ctx.restore(); + } + ctx.restore(); + if (this.advancedMode) { + ctx.fillStyle = "#ff9800"; + ctx.font = "12px Arial"; + ctx.textAlign = "left"; + ctx.fillText(`EDITING (Scale: ${(useScale * 100).toFixed(0)}%)`, this.overlayCanvas.width - 200, this.overlayCanvas.height - 20); + } + }; + + nodeType.prototype.openDeferredEditor = function() { + if (!this.pendingEditorData) return; + const data = this.pendingEditorData; + this.textMask = null; + this.outlineMask = null; + this._glowMask = null; + this._textMaskFile = null; + this._glowMaskTimestamp = null; + this._glowExtraPadding = 0; + this._glowKey = null; + this._shadowMask = null; + this._shadowKey = null; + this.backgroundImage = null; + this.isLoading = true; + this.currentSessionTimestamp = data.timestamp; + this.textParams.text_scale_x = 1.0; + this.textParams.text_scale_y = 1.0; + this.awaitingServerRender = false; + if (this.fontList.length === 0 && data.font_list && data.font_list.length > 0) { + this.fontList = data.font_list; + if (!this.textParams.font_name) { + this.textParams.font_name = data.default_font || this.fontList[0]; + } + } + this.realBackground = { width: data.bg_width, height: data.bg_height }; + this.canvasRealWidth = this.realBackground.width; + this.canvasRealHeight = this.realBackground.height; + this.updateDisplaySize(this.canvasPixelSize); + const bgFile = data.bg_file, ts = data.timestamp; + const img = new Image(); + img.crossOrigin = "Anonymous"; + img.onload = () => { + this.backgroundImage = img; + this.isLoading = false; + const initialWidth = this.displayWidth; + const initialHeight = this.displayWidth * (3 / 5); + this.overlayRelative = { + width: initialWidth / this.displayWidth, + height: initialHeight / this.displayHeight, + x: 0.5, + y: 0.5, + rotation: 0 + }; + this.updateOverlayAbsolute(); + this.computeAndApplyView(); + this.isEditing = true; + this.baseWidth = this.overlay.width; + this.baseHeight = this.overlay.height; + this.setDirtyCanvas(true); + this.renderMasks(); + this.syncAllWidgets(); + }; + img.onerror = () => { + this.isLoading = false; + }; + img.src = `/view?filename=${bgFile}&type=temp&t=${ts}`; + }; + + nodeType.prototype.renderMasks = async function(signal = null) { + if (!this.isEditing || !this.backgroundImage) return; + const scale = this.realBackground.width / this.displayWidth; + const containerWidth = Math.round(this.baseWidth * scale); + const containerHeight = Math.round(this.baseHeight * scale); + if (containerWidth < 10 || containerHeight < 10) return; + try { + const response = await api.fetchApi("/rayko/rs_overlay_pro/render_masks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + node_id: String(this.id), + text_params: this.textParams, + container_width: containerWidth, + container_height: containerHeight + }), + signal: signal + }); + const data = await response.json(); + if (data.error) { + this.awaitingServerRender = false; + return; + } + let loadedCount = 0; + const totalMasks = 2; + const checkDone = () => { + if (loadedCount === totalMasks) { + this.awaitingServerRender = false; + this.setDirtyCanvas(true); + this.requestGlowMask(); + this.requestShadowMask(); + } + }; + const textImg = new Image(); + textImg.crossOrigin = "Anonymous"; + textImg.onload = () => { + this.textMask = textImg; + this._textMaskFile = data.text_mask_file; + loadedCount++; + checkDone(); + }; + textImg.onerror = () => { + loadedCount++; + checkDone(); + }; + textImg.src = `/view?filename=${data.text_mask_file}&type=temp&t=${data.timestamp}`; + + const outlineImg = new Image(); + outlineImg.crossOrigin = "Anonymous"; + outlineImg.onload = () => { + this.outlineMask = outlineImg; + loadedCount++; + checkDone(); + }; + outlineImg.onerror = () => { + loadedCount++; + checkDone(); + }; + outlineImg.src = `/view?filename=${data.outline_mask_file}&type=temp&t=${data.timestamp}`; + } catch (e) { + if (e.name === 'AbortError') return; + this.awaitingServerRender = false; + } + }; + + nodeType.prototype.updateOverlayAbsolute = function() { + this.overlay.x = (this.overlayRelative.x - 0.5) * this.displayWidth; + this.overlay.y = (this.overlayRelative.y - 0.5) * this.displayHeight; + this.overlay.width = this.overlayRelative.width * this.displayWidth; + this.overlay.height = this.overlayRelative.height * this.displayHeight; + this.overlay.rotation = this.overlayRelative.rotation; + }; + + nodeType.prototype.updateRelativeFromAbsolute = function() { + this.overlayRelative.x = (this.overlay.x / this.displayWidth) + 0.5; + this.overlayRelative.y = (this.overlay.y / this.displayHeight) + 0.5; + this.overlayRelative.width = this.overlay.width / this.displayWidth; + this.overlayRelative.height = this.overlay.height / this.displayHeight; + this.overlayRelative.rotation = this.overlay.rotation; + }; + + nodeType.prototype.computeAndApplyView = function() { + const bgW = this.displayWidth, bgH = this.displayHeight; + const ovW = this.overlay.width, ovH = this.overlay.height; + const ovL = this.overlay.x - ovW/2, ovT = this.overlay.y - ovH/2; + const ovR = this.overlay.x + ovW/2, ovB = this.overlay.y + ovH/2; + const bgL = -bgW/2, bgT = -bgH/2, bgR = bgW/2, bgB = bgH/2; + const minX = Math.min(ovL, bgL), minY = Math.min(ovT, bgT), maxX = Math.max(ovR, bgR), maxY = Math.max(ovB, bgB); + const contentW = Math.max(1, maxX - minX), contentH = Math.max(1, maxY - minY); + const contentCX = (minX + maxX)/2, contentCY = (minY + maxY)/2; + let glowPadding = this.glowPaddingPreview || 0; + if (this.advancedMode) { + const cw = this.overlayCanvas.width || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientWidth : 1000); + const ch = this.overlayCanvas.height || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientHeight : 800); + const availableW = cw * 0.8, availableH = ch * 0.8; + const totalW = contentW + glowPadding*2; + const totalH = contentH + glowPadding*2; + this.viewScale = Math.max(0.1, Math.min(3.0, Math.min(availableW/totalW, availableH/totalH))); + this.viewOffsetX = cw/2 - (contentCX * this.viewScale); + this.viewOffsetY = ch/2 - (contentCY * this.viewScale); + } else { + const availableW = this.canvasPixelSize * 0.95, availableH = this.canvasPixelSize * 0.95; + const totalW = contentW + glowPadding*2; + const totalH = contentH + glowPadding*2; + this.viewScale = Math.max(0.1, Math.min(3.0, Math.min(availableW/totalW, availableH/totalH))); + this.viewOffsetX = this.canvasPixelSize/2 - (contentCX * this.viewScale); + this.viewOffsetY = this.canvasPixelSize/2 - (contentCY * this.viewScale); + } + }; + + nodeType.prototype.updateDisplaySize = function(cS) { + this.canvasPixelSize = cS; + const safeHeight = this.realBackground.height || 1; + const bgAR = this.realBackground.width / safeHeight; + if (bgAR >= 1) { + this.displayWidth = cS; + this.displayHeight = cS / bgAR; + } else { + this.displayHeight = cS; + this.displayWidth = cS * bgAR; + } + }; + + nodeType.prototype.getRealTransform = function() { + const dS = this.canvasRealWidth / (this.displayWidth || 1); + const absX = (this.overlay.x * dS) + (this.canvasRealWidth / 2); + const absY = (this.overlay.y * dS) + (this.canvasRealHeight / 2); + const scaleX = this.overlay.width / this.displayWidth; + const scaleY = this.overlay.height / this.displayHeight; + const baseWidthPx = this.baseWidth * dS; + const baseHeightPx = this.baseHeight * dS; + return { + x: absX, + y: absY, + scale_x: scaleX, + scale_y: scaleY, + rotation: this.overlay.rotation, + base_width_px: baseWidthPx, + base_height_px: baseHeightPx + }; + }; + + nodeType.prototype.computeScreenHandles = function(rectX, rectY, useScale, useOffsetX, useOffsetY) { + const hw = this.overlay.width / 2; + const hh = this.overlay.height / 2; + const rot = this.overlay.rotation * Math.PI / 180; + const cos = Math.cos(rot), sin = Math.sin(rot); + const handles = { + 'scale-tl': [-hw, -hh], + 'scale-tr': [hw, -hh], + 'scale-bl': [-hw, hh], + 'scale-br': [hw, hh], + 'scale-t': [0, -hh], + 'scale-b': [0, hh], + 'scale-l': [-hw, 0], + 'scale-r': [hw, 0], + 'rotate': [0, -hh - 40] + }; + const screenHandles = {}; + for (const [name, loc] of Object.entries(handles)) { + const rx = loc[0]*cos - loc[1]*sin, ry = loc[0]*sin + loc[1]*cos; + screenHandles[name] = { x: rectX + useOffsetX + (this.overlay.x + rx)*useScale, y: rectY + useOffsetY + (this.overlay.y + ry)*useScale }; + } + return screenHandles; + }; + + nodeType.prototype.sendTransforms = async function() { + if (this.currentRenderAbortController) { + this.currentRenderAbortController.abort(); + this.currentRenderAbortController = null; + } + const transform = this.getRealTransform(); + const scale = this.realBackground.width / this.displayWidth; + const textParamsWithBase = { + ...this.textParams, + base_width_px: transform.base_width_px, + base_height_px: transform.base_height_px, + shadow_offset_x: Math.round(this.textParams.shadow_offset_x * scale), + shadow_offset_y: Math.round(this.textParams.shadow_offset_y * scale), + shadow_blur: Math.round((this.textParams.shadow_blur || 0) * scale) + }; + const payload = { + id: String(this.id), + transforms: transform, + text_params: textParamsWithBase + }; + try { + await api.fetchApi("/rayko/rs_overlay_pro", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + this.isEditing = false; + this.setDirtyCanvas(true); + } catch(e) {} + }; + + nodeType.prototype.cancelEditing = async function() { + if (this.currentRenderAbortController) { + this.currentRenderAbortController.abort(); + this.currentRenderAbortController = null; + } + if (this._glowMaskTimeout) { + clearTimeout(this._glowMaskTimeout); + this._glowMaskTimeout = null; + } + if (this._glowMaskAbortController) { + this._glowMaskAbortController.abort(); + this._glowMaskAbortController = null; + } + if (this._shadowMaskTimeout) { + clearTimeout(this._shadowMaskTimeout); + this._shadowMaskTimeout = null; + } + if (this._shadowMaskAbortController) { + this._shadowMaskAbortController.abort(); + this._shadowMaskAbortController = null; + } + try { await api.interrupt(); } catch(e) {} + await fetch("/rayko/rs_overlay_pro/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(this.id) }) }); + this.isEditing = false; this.isLoading = false; this.dragType = null; this.dragState = null; this.awaitingServerRender = false; + this._glowMask = null; this._textMaskFile = null; this._glowExtraPadding = 0; this._glowKey = null; + this._shadowMask = null; this._shadowKey = null; + this.setDirtyCanvas(true); + this.closeTextarea(); + }; + + nodeType.prototype.roundRect = function(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + }; + + nodeType.prototype.getNodeScreenCoords = function(localX, localY) { + try { + const canvas = LGraphCanvas.active_canvas; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.canvas.getBoundingClientRect(); + const ds = canvas.ds; + return { + x: rect.left + (this.pos[0] + localX) * ds.scale + ds.offset[0], + y: rect.top + (this.pos[1] + localY) * ds.scale + ds.offset[1] + }; + } catch(e) { + return { x: 0, y: 0 }; + } + }; + + nodeType.prototype.openTextarea = function(clickEvent) { + if (this.textareaState.isOpen && this.textareaState.element) { + this.textareaState.element.remove(); + } + const currentValue = this.textParams.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'; + } + document.body.appendChild(popup); + setTimeout(() => { + input.focus(); + setTimeout(() => { + if (currentValue && currentValue.length > 0) input.select(); + }, 10); + }, 50); + saveBtn.onclick = (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + this.textParams.text = input.value; + popup.remove(); + this.textareaState.isOpen = false; + this.textareaState.element = null; + this.setDirtyCanvas(true); + if (this.scheduleRender) this.scheduleRender('text'); + this.syncAllWidgets(); + }; + input.onkeydown = (ev) => { + if (ev.key === 'Enter' && ev.ctrlKey) { + ev.preventDefault(); + this.textParams.text = input.value; + popup.remove(); + this.textareaState.isOpen = false; + this.textareaState.element = null; + this.setDirtyCanvas(true); + if (this.scheduleRender) this.scheduleRender('text'); + this.syncAllWidgets(); + } + }; + this.textareaState.isOpen = true; + this.textareaState.element = popup; + }; + + nodeType.prototype.closeTextarea = function() { + if (this.textareaState.element) { + this.textareaState.element.remove(); + this.textareaState.isOpen = false; + this.textareaState.element = null; + } + }; + + nodeType.prototype.showFontSelector = function(clickEvent) { + if (this.fontMenu) { + this.fontMenu.remove(); + this.fontMenu = null; + } + const list = this.fontList || []; + if (!list.length) return; + const buttonBottomLeft = this.getNodeScreenCoords( + this.fontSelectRect.x, + this.fontSelectRect.y + this.fontSelectRect.h + ); + const buttonTopLeft = this.getNodeScreenCoords( + this.fontSelectRect.x, + this.fontSelectRect.y + ); + const menu = document.createElement("div"); + menu.style.cssText = 'position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;max-height:300px;overflow:hidden;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:200px;display:flex;flex-direction:column;'; + this.fontMenu = menu; + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.placeholder = "Search fonts..."; + searchInput.value = ""; + searchInput.style.cssText = 'width:100%;background:#222;color:#eee;border:none;border-bottom:1px solid #333;border-radius:6px 6px 0 0;padding:8px 12px;font-size:12px;outline:none;box-sizing:border-box;'; + const listContainer = document.createElement("div"); + listContainer.style.cssText = 'overflow-y:auto;flex:1;'; + const renderList = (filter) => { + listContainer.innerHTML = ''; + const lowerFilter = filter.toLowerCase(); + const filtered = list.filter(f => f.toLowerCase().includes(lowerFilter)); + if (filtered.length === 0) { + const empty = document.createElement("div"); + empty.textContent = "No matches"; + empty.style.cssText = 'padding:10px 15px;color:#666;font-size:12px;font-style:italic;'; + listContainer.appendChild(empty); + return; + } + filtered.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(); + this.textParams.font_name = opt; + menu.remove(); + this.fontMenu = null; + this.setDirtyCanvas(true); + if (this.scheduleRender) this.scheduleRender('font_name'); + this.syncAllWidgets(); + }; + listContainer.appendChild(item); + }); + }; + searchInput.addEventListener('input', (e) => { + renderList(e.target.value); + }); + searchInput.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }); + searchInput.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Escape') { + menu.remove(); + this.fontMenu = null; + } + }); + menu.appendChild(searchInput); + menu.appendChild(listContainer); + menu.style.left = buttonBottomLeft.x + 'px'; + menu.style.top = buttonBottomLeft.y + 'px'; + document.body.appendChild(menu); + renderList(''); + const menuHeight = menu.offsetHeight; + const menuWidth = menu.offsetWidth; + let finalLeft = buttonBottomLeft.x; + let finalTop = buttonBottomLeft.y; + if (finalTop + menuHeight > window.innerHeight - 10) { + finalTop = buttonTopLeft.y - menuHeight; + if (finalTop < 10) finalTop = 10; + } + if (finalLeft + menuWidth > window.innerWidth - 10) { + finalLeft = window.innerWidth - menuWidth - 10; + } + menu.style.left = finalLeft + 'px'; + menu.style.top = finalTop + 'px'; + setTimeout(() => { + searchInput.focus(); + }, 50); + setTimeout(() => { + const closeHandler = (ev) => { + if (!menu.contains(ev.target)) { + menu.remove(); + this.fontMenu = null; + document.removeEventListener("mousedown", closeHandler); + } + }; + document.addEventListener("mousedown", closeHandler); + }, 100); + }; + + nodeType.prototype.showHexEditor = function() { + if (this.hexEditor) { + this.hexEditor.remove(); + this.hexEditor = null; + } + const currentValue = this.textParams.text_color || '#FFFFFF'; + const pos = this.getNodeScreenCoords(this.hexRect.x, this.hexRect.y); + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;z-index:10002;background:#1a1a1a;border:1px solid #4CAF50;border-radius:4px;padding:6px 8px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:6px;'; + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentValue; + input.style.cssText = 'width:80px;background:#222;color:#fff;border:1px solid #444;border-radius:3px;padding:4px 8px;font-size:11px;font-family:monospace;outline:none;text-transform:uppercase;'; + const okBtn = document.createElement('button'); + okBtn.textContent = 'OK'; + okBtn.style.cssText = 'background:#2196F3;color:#fff;border:none;border-radius:3px;padding:4px 10px;font-size:11px;cursor:pointer;'; + okBtn.onmouseover = () => okBtn.style.background = "#1976D2"; + okBtn.onmouseout = () => okBtn.style.background = "#2196F3"; + container.appendChild(input); + container.appendChild(okBtn); + document.body.appendChild(container); + const containerHeight = container.offsetHeight; + container.style.left = pos.x + 'px'; + container.style.top = (pos.y - containerHeight * 1.5) + 'px'; + this.hexEditor = container; + const isValidHex = (val) => { + const cleaned = val.replace('#', ''); + return /^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(cleaned); + }; + const normalizeHex = (val) => { + let cleaned = val.replace('#', '').toUpperCase(); + if (cleaned.length === 3) { + cleaned = cleaned.split('').map(c => c + c).join(''); + } + return '#' + cleaned; + }; + input.addEventListener('input', (e) => { + const val = e.target.value; + if (isValidHex(val)) { + input.style.borderColor = '#4CAF50'; + const normalized = normalizeHex(val); + this.textParams.text_color = normalized; + this.setDirtyCanvas(true); + } else { + input.style.borderColor = '#dc3545'; + } + }); + const applyAndClose = () => { + const val = input.value; + if (isValidHex(val)) { + this.textParams.text_color = normalizeHex(val); + this.setDirtyCanvas(true); + } + container.remove(); + this.hexEditor = null; + }; + okBtn.onclick = (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + applyAndClose(); + }; + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + applyAndClose(); + } else if (e.key === 'Escape') { + e.preventDefault(); + container.remove(); + this.hexEditor = null; + } + }); + input.addEventListener('blur', () => { + setTimeout(() => { + if (this.hexEditor === container) { + applyAndClose(); + } + }, 100); + }); + setTimeout(() => { + input.focus(); + input.select(); + }, 50); + }; + + nodeType.prototype.openColorPicker = function(clickEvent) { + const currentColor = this.textParams.text_color || '#FFFFFF'; + 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(); + this.textParams.text_color = newColor; + this.setDirtyCanvas(true); + if (this.scheduleRender) this.scheduleRender('text_color'); + this.syncAllWidgets(); + colorInput.remove(); + }, { once: true }); + setTimeout(() => { + if (colorInput.showPicker) { + colorInput.showPicker(); + } else { + colorInput.click(); + } + }, 10); + }; + + nodeType.prototype.updateLineSpacingFromMouse = function(localX) { + if (!this.lineSpacingTrackRect) return; + const { x: trackX, w: trackW } = this.lineSpacingTrackRect; + let ratio = (localX - trackX) / trackW; + ratio = Math.max(0, Math.min(1, ratio)); + const newValue = 0.5 + ratio * 2.5; + this.textParams.line_spacing = Math.round(newValue * 20) / 20; + this.setDirtyCanvas(true); + if (this.scheduleRender) this.scheduleRender('line_spacing'); + this.syncAllWidgets(); + }; + + nodeType.prototype.onResize = function(size) { + if (size[0] < this.minWidth) size[0] = this.minWidth; + const titleH = LiteGraph.NODE_TITLE_HEIGHT || 30; + const canvasTopPadding = 30; + const cSize = Math.max(200, size[0] - 40); + this.canvasPixelSize = cSize; + const textareaH = 60; + const alignH = 27; + const fontColorH = 27; + const btnH = 30; + const gaps = 10 + 8 + 8 + 20 + 15; + const neededHeight = titleH + canvasTopPadding + cSize + textareaH + alignH + fontColorH + btnH + gaps; + if (size[1] < neededHeight) size[1] = neededHeight; + this.setDirtyCanvas(true); + if (this.isEditing && this.backgroundImage) { + this.updateDisplaySize(cSize); + this.updateOverlayAbsolute(); + this.computeAndApplyView(); + } + }; + + nodeType.prototype.getCanvasMetrics = function() { + const titleH = LiteGraph.NODE_TITLE_HEIGHT || 30; + const canvasTopPadding = 30; + let cSize, rectX, rectY; + if (this.advancedMode) { + const w = this.overlayCanvas.width || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientWidth : 1000); + const h = this.overlayCanvas.height || (this.overlayCanvasWrapper ? this.overlayCanvasWrapper.clientHeight : 800); + cSize = Math.min(w, h); + rectX = 0; rectY = 0; + } else { + cSize = Math.max(200, this.size[0] - 40); + rectX = (this.size[0] - cSize) / 2; + rectY = titleH + canvasTopPadding; + } + return { cSize, rectX, rectY }; + }; + + nodeType.prototype.drawAlignIcon = function(ctx, type, x, y, w, h, isActive) { + const lineHeight = 2; + const gap = 3; + const totalH = lineHeight*3 + gap*2; + const startY = y + (h - totalH)/2; + ctx.fillStyle = isActive ? "#4CAF50" : "#aaa"; + if (type === "left") { + ctx.fillRect(x, startY, w*0.9, lineHeight); + ctx.fillRect(x, startY + lineHeight + gap, w*0.7, lineHeight); + ctx.fillRect(x, startY + (lineHeight+gap)*2, w*0.5, lineHeight); + } else if (type === "center") { + const w1 = w*0.9, w2 = w*0.7, w3 = w*0.5; + ctx.fillRect(x + (w-w1)/2, startY, w1, lineHeight); + ctx.fillRect(x + (w-w2)/2, startY + lineHeight + gap, w2, lineHeight); + ctx.fillRect(x + (w-w3)/2, startY + (lineHeight+gap)*2, w3, lineHeight); + } else if (type === "right") { + ctx.fillRect(x + w - w*0.9, startY, w*0.9, lineHeight); + ctx.fillRect(x + w - w*0.7, startY + lineHeight + gap, w*0.7, lineHeight); + ctx.fillRect(x + w - w*0.5, startY + (lineHeight+gap)*2, w*0.5, lineHeight); + } + }; + + nodeType.prototype.drawLineSpacingIcon = function(ctx, x, y, w, h) { + const lineY1 = y + h*0.3; + const lineY2 = y + h*0.7; + const arrowX = x + w/2; + ctx.strokeStyle = "#aaa"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x+2, lineY1); + ctx.lineTo(x+w-2, lineY1); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x+2, lineY2); + ctx.lineTo(x+w-2, lineY2); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(arrowX, lineY1+2); + ctx.lineTo(arrowX, lineY2-2); + ctx.stroke(); + ctx.fillStyle = "#aaa"; + ctx.beginPath(); + ctx.moveTo(arrowX, lineY1+1); + ctx.lineTo(arrowX-2, lineY1+4); + ctx.lineTo(arrowX+2, lineY1+4); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(arrowX, lineY2-1); + ctx.lineTo(arrowX-2, lineY2-4); + ctx.lineTo(arrowX+2, lineY2-4); + ctx.fill(); + }; + + nodeType.prototype.onDrawForeground = function(ctx) { + if (this.advancedMode) { + ctx.clearRect(0, 0, this.size[0], this.size[1]); + return; + } + const { cSize, rectX, rectY } = this.getCanvasMetrics(); + ctx.fillStyle = "#1e1e1e"; ctx.fillRect(rectX, rectY, cSize, cSize); + ctx.strokeStyle = "#555"; ctx.strokeRect(rectX, rectY, cSize, cSize); + this.updateDisplaySize(cSize); + if (this.isEditing && this.backgroundImage) { + this.updateOverlayAbsolute(); + this.computeAndApplyView(); + } + if (!this.dragState) { this.updateOverlayAbsolute(); this.computeAndApplyView(); } + if (this.isLoading) { + ctx.fillStyle = "#888"; ctx.font = "12px Arial"; ctx.fillText("Loading...", rectX + cSize/2 - 35, rectY + cSize/2); + } else if (this.isEditing && this.backgroundImage) { + const useScale = this.dragState ? this.dragState.viewScale : this.viewScale; + const useOffsetX = this.dragState ? this.dragState.viewOffsetX : this.viewOffsetX; + const useOffsetY = this.dragState ? this.dragState.viewOffsetY : this.viewOffsetY; + ctx.save(); ctx.translate(rectX + useOffsetX, rectY + useOffsetY); ctx.scale(useScale, useScale); + ctx.drawImage(this.backgroundImage, -this.displayWidth/2, -this.displayHeight/2, this.displayWidth, this.displayHeight); + if (this.textMask) { + ctx.save(); + ctx.translate(this.overlay.x, this.overlay.y); + ctx.rotate(this.overlay.rotation * Math.PI / 180); + const rectW = this.overlay.width; + const rectH = this.overlay.height; + this.drawTextOverlay(ctx, -rectW / 2, -rectH / 2, rectW, rectH); + if (this.advancedMode) { + ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 4/useScale; ctx.strokeStyle = "#00FF00"; ctx.lineWidth = 1/useScale; + ctx.strokeRect(-rectW/2, -rectH/2, rectW, rectH); ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; + const hw = rectW/2, hh = rectH/2, hs = 6/useScale; ctx.fillStyle = "#FF0000"; + [[hw, hh], [-hw, hh], [hw, -hh], [-hw, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs/2, y - hs/2, hs, hs)); + [[hw, 0], [-hw, 0], [0, hh], [0, -hh]].forEach(([x, y]) => ctx.fillRect(x - hs/2, y - hs/2, hs, hs)); + const rotHandleY = -hh - 40; ctx.beginPath(); ctx.arc(0, rotHandleY, 7/useScale, 0, Math.PI*2); ctx.fillStyle = "#ff9800"; ctx.fill(); ctx.strokeStyle = "#fff"; ctx.lineWidth = 1/useScale; ctx.stroke(); + } + ctx.restore(); + } + ctx.restore(); + if (this.advancedMode) { + ctx.fillStyle = "#ff9800"; ctx.font = "12px Arial"; ctx.fillText(`EDITING (Scale: ${(useScale*100).toFixed(0)}%)`, rectX + cSize - 160, rectY + cSize - 10); + } + } else { + ctx.fillStyle = "#888"; ctx.font = "12px Arial"; ctx.fillText("▶ Run queue to start", rectX + cSize/2 - 65, rectY + cSize/2); + } + const toggleBtnW = 150, toggleBtnH = 24, toggleBtnX = (this.size[0] - toggleBtnW)/2, toggleBtnY = 20; + ctx.fillStyle = "#2a2a2a"; ctx.strokeStyle = "#2196F3"; ctx.lineWidth = 2; + this.roundRect(ctx, toggleBtnX, toggleBtnY, toggleBtnW, toggleBtnH, 6); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "#2196F3"; ctx.font = "bold 11px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; + ctx.fillText("🔍 ADVANCED MODE", toggleBtnX + toggleBtnW/2, toggleBtnY + toggleBtnH/2 + 4); + const widgetX = 15; + const widgetW = this.size[0] - 30; + const textareaY = rectY + cSize + 10; + const textareaH = 60; + this.textareaRect = { x: widgetX, y: textareaY, w: widgetW, h: textareaH }; + ctx.fillStyle = this.textareaHover ? "#2a2a2a" : "#252525"; + ctx.strokeStyle = this.textareaHover ? "#4CAF50" : "#444"; + ctx.lineWidth = 1; + this.roundRect(ctx, widgetX, textareaY, widgetW, textareaH, 4); + ctx.fill(); + ctx.stroke(); + ctx.save(); + ctx.beginPath(); + ctx.rect(widgetX+1, textareaY+1, widgetW-2, textareaH-2); + ctx.clip(); + ctx.font = "12px monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + const text = this.textParams.text || ""; + const lines = text.split('\n'); + const maxLines = 3; + const lineHeight = 16; + const paddingX = 8; + const paddingY = 6; + if (!text) { + ctx.fillStyle = "#666"; + ctx.fillText("Your text…", widgetX+paddingX, textareaY+paddingY); + } else { + for (let i = 0; i < Math.min(lines.length, maxLines); i++) { + let line = lines[i]; + const maxWidth = widgetW - paddingX*2; + while (ctx.measureText(line).width > maxWidth && line.length > 0) { + line = line.slice(0, -1); + } + if (line !== lines[i]) line += "…"; + ctx.fillStyle = "#aaa"; + ctx.fillText(line, widgetX+paddingX, textareaY+paddingY+i*lineHeight); + } + if (lines.length > maxLines) { + ctx.fillStyle = "#666"; + ctx.fillText("…", widgetX+paddingX, textareaY+paddingY+maxLines*lineHeight); + } + } + ctx.restore(); + const alignY = textareaY + textareaH + 8; + const alignH = 27; + const padding = 5; + const availableW = widgetW - padding*2; + const sliderW = availableW * 0.7; + const sliderX = widgetX + padding; + this.lineSpacingRect = { x: sliderX, y: alignY, w: sliderW, h: alignH }; + const btnW = availableW * 0.1; + const btnGap = 4; + const btnStartX = sliderX + sliderW + btnGap; + const alignTypes = ["left", "center", "right"]; + this.alignButtonsRects = []; + const isMultiline = text.includes('\n'); + ctx.fillStyle = this.lineSpacingHover ? "#2a2a2a" : "#252525"; + ctx.strokeStyle = this.lineSpacingHover ? "#4CAF50" : "#444"; + ctx.lineWidth = 1; + this.roundRect(ctx, sliderX, alignY, sliderW, alignH, 4); + ctx.fill(); + ctx.stroke(); + this.drawLineSpacingIcon(ctx, sliderX+6, alignY+6, 14, 15); + const trackX = sliderX + 24; + const trackW = sliderW - 70; + const trackY = alignY + alignH/2; + const trackH = 3; + this.lineSpacingTrackRect = { x: trackX, y: alignY, w: trackW, h: alignH }; + ctx.fillStyle = "#444"; + ctx.fillRect(trackX, trackY - trackH/2, trackW, trackH); + const lineSpacingVal = parseFloat(this.textParams.line_spacing) || 1.0; + const fillRatio = Math.max(0, Math.min(1, (lineSpacingVal - 0.5)/2.5)); + const fillW = trackW * fillRatio; + ctx.fillStyle = "#4CAF50"; + ctx.fillRect(trackX, trackY - trackH/2, fillW, trackH); + const handleX = trackX + fillW; + const handleSize = 8; + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.arc(handleX, trackY, handleSize/2, 0, Math.PI*2); + ctx.fill(); + const valueW = 42; + const valueX = sliderX + sliderW - valueW - 4; + this.lineSpacingValueRect = { x: valueX, y: alignY, w: valueW, h: alignH }; + ctx.fillStyle = this.lineSpacingValueHover ? "#2a2a2a" : "#222"; + ctx.strokeStyle = this.lineSpacingValueHover ? "#4CAF50" : "#444"; + ctx.lineWidth = 1; + this.roundRect(ctx, valueX, alignY+3, valueW, alignH-6, 3); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#4CAF50"; + ctx.font = "10px monospace"; + ctx.textAlign = "center"; + ctx.fillText(lineSpacingVal.toFixed(2), valueX+valueW/2, alignY+alignH/2+3); + for (let i = 0; i < 3; i++) { + const bx = btnStartX + i*(btnW+btnGap); + const isActive = this.textParams.text_align === alignTypes[i]; + const isHover = this.alignButtonsHover[i]; + const isEnabled = isMultiline; + this.alignButtonsRects.push({ x: bx, y: alignY, w: btnW, h: alignH, type: alignTypes[i], enabled: isEnabled }); + ctx.fillStyle = isHover && isEnabled ? "#2a2a2a" : "#252525"; + ctx.strokeStyle = isActive && isEnabled ? "#4CAF50" : (isHover && isEnabled ? "#4CAF50" : "#444"); + ctx.lineWidth = isActive && isEnabled ? 2 : 1; + ctx.globalAlpha = isEnabled ? 1.0 : 0.4; + this.roundRect(ctx, bx, alignY, btnW, alignH, 4); + ctx.fill(); + ctx.stroke(); + ctx.globalAlpha = 1.0; + if (isEnabled) { + this.drawAlignIcon(ctx, alignTypes[i], bx + (btnW-14)/2, alignY + (alignH-10)/2, 14, 10, isActive); + } + } + const fontColorY = alignY + alignH + 8; + const fontColorH = 27; + const gap = 8; + const fontSelectW = Math.floor((widgetW - gap) * 0.6); + const colorPickerW = widgetW - fontSelectW - gap; + const fontSelectX = widgetX; + const colorPickerX = widgetX + fontSelectW + gap; + this.fontColorY = fontColorY; + this.fontSelectRect = { x: fontSelectX, y: fontColorY, w: fontSelectW, h: fontColorH }; + ctx.fillStyle = this.fontSelectHover ? "#2a2a2a" : "#252525"; + ctx.strokeStyle = this.fontSelectHover ? "#4CAF50" : "#444"; + ctx.lineWidth = 1; + this.roundRect(ctx, fontSelectX, fontColorY, fontSelectW, fontColorH, 4); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#fff"; + ctx.font = "11px sans-serif"; + ctx.textAlign = "center"; + const fontName = this.textParams.font_name || "Fonts not found"; + ctx.fillText(fontName, fontSelectX+fontSelectW/2, fontColorY+fontColorH/2+4); + ctx.fillStyle = "#666"; + ctx.beginPath(); + ctx.moveTo(fontSelectX+fontSelectW-12, fontColorY+fontColorH/2-3); + ctx.lineTo(fontSelectX+fontSelectW-6, fontColorY+fontColorH/2-3); + ctx.lineTo(fontSelectX+fontSelectW-9, fontColorY+fontColorH/2+3); + ctx.fill(); + const colorBoxSize = 24; + const colorBoxY = fontColorY + Math.round((fontColorH - colorBoxSize)/2); + const hexW = colorPickerW - colorBoxSize - 5; + const hexX = colorPickerX; + const colorBoxX = colorPickerX + hexW + 5; + this.hexRect = { x: hexX, y: fontColorY, w: hexW, h: fontColorH }; + this.colorBoxRect = { x: colorBoxX, y: colorBoxY, w: colorBoxSize, h: colorBoxSize }; + ctx.fillStyle = this.hexHover ? "#2a2a2a" : "#252525"; + ctx.fillRect(hexX, fontColorY, hexW, fontColorH); + ctx.strokeStyle = this.hexHover ? "#4CAF50" : "#444"; + ctx.lineWidth = 1; + ctx.strokeRect(hexX, fontColorY, hexW, fontColorH); + ctx.fillStyle = "#fff"; + ctx.font = "11px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(this.textParams.text_color || "#FFFFFF", hexX+hexW/2, fontColorY+fontColorH/2+4); + ctx.fillStyle = this.textParams.text_color || "#FFFFFF"; + this.roundRect(ctx, colorBoxX, colorBoxY, colorBoxSize, colorBoxSize, 4); + ctx.fill(); + ctx.strokeStyle = this.colorBoxHover ? "#4CAF50" : "#fff"; + ctx.lineWidth = 1.5; + this.roundRect(ctx, colorBoxX, colorBoxY, colorBoxSize, colorBoxSize, 4); + ctx.stroke(); + const buttonsY = fontColorY + fontColorH + 20; + this.buttonsY = buttonsY; + const btnH = 30; + const btnGap2 = 10; + const btnW2 = (this.size[0] - 35) / 2; + [[15, buttonsY, "✔️ APPLY", this.btnApplyHover, "#4CAF50"], [15+btnW2+btnGap2, buttonsY, " CANCEL", this.btnCancelHover, "#dc3545"]].forEach(([bx, by, txt, hov, col]) => { + ctx.fillStyle = hov ? "#444" : "#2a2a2a"; + this.roundRect(ctx, bx, by, btnW2, btnH, 6); + ctx.fill(); + ctx.strokeStyle = col; + ctx.stroke(); + ctx.fillStyle = col; + ctx.font = "bold 11px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(txt, bx+btnW2/2, by+btnH/2+4); + }); + }; + + nodeType.prototype.onMouseDown = function(event, pos) { + if (!this.advancedMode && pos) { + const toggleBtnW = 150, toggleBtnH = 24, toggleBtnX = (this.size[0] - toggleBtnW)/2, toggleBtnY = 20; + if (pos[0] >= toggleBtnX && pos[0] <= toggleBtnX + toggleBtnW && pos[1] >= toggleBtnY && pos[1] <= toggleBtnY + toggleBtnH) { + this._toggleAdvancedMode(); + return true; + } + } + if (this.textareaRect) { + const { x, y, w, h } = this.textareaRect; + if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + const canvas = LGraphCanvas.active_canvas; + if (canvas) { + const rect = canvas.canvas.getBoundingClientRect(); + const ds = canvas.ds; + this.lastClickCoords = { + clientX: rect.left + (this.pos[0] + pos[0]) * ds.scale + ds.offset[0], + clientY: rect.top + (this.pos[1] + pos[1]) * ds.scale + ds.offset[1] + }; + } + this.openTextarea(this.lastClickCoords); + return true; + } + } + if (this.lineSpacingValueRect) { + const { x, y, w, h } = this.lineSpacingValueRect; + if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + if (this.lineSpacingPopup) { + this.lineSpacingPopup.remove(); + this.lineSpacingPopup = null; + } + const currentValue = this.textParams.line_spacing; + 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;'; + const input = document.createElement('input'); + input.type = 'number'; + input.value = currentValue; + input.min = 0.5; + input.max = 3.0; + input.step = 0.05; + 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.style.cssText = 'background:#2196F3;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;'; + const doSave = () => { + let num = parseFloat(input.value); + if (isNaN(num)) num = currentValue; + num = Math.max(0.5, Math.min(3.0, num)); + this.textParams.line_spacing = num; + popup.remove(); + this.lineSpacingPopup = null; + if (this.scheduleRender) this.scheduleRender('line_spacing'); + this.syncAllWidgets(); + }; + saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); }; + input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } }; + popup.appendChild(input); + popup.appendChild(saveBtn); + document.body.appendChild(popup); + const screenPos = this.getNodeScreenCoords(x, y); + const popupWidth = popup.offsetWidth; + const popupHeight = popup.offsetHeight; + let leftPos = screenPos.x; + let topPos = screenPos.y - popupHeight - 8; + if (topPos < 8) topPos = screenPos.y + 8; + if (leftPos + popupWidth > window.innerWidth - 8) leftPos = window.innerWidth - popupWidth - 8; + popup.style.left = leftPos + 'px'; + popup.style.top = topPos + 'px'; + this.lineSpacingPopup = popup; + setTimeout(() => { input.focus(); input.select(); }, 50); + setTimeout(() => { + const closeHandler = (ev) => { + if (!popup.contains(ev.target)) { + popup.remove(); + this.lineSpacingPopup = null; + document.removeEventListener('mousedown', closeHandler); + } + }; + document.addEventListener('mousedown', closeHandler); + }, 100); + return true; + } + } + if (this.lineSpacingTrackRect) { + const { x, y, w, h } = this.lineSpacingTrackRect; + if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + this.lineSpacingDragging = true; + this.updateLineSpacingFromMouse(pos[0]); + return true; + } + } + if (this.alignButtonsRects && this.alignButtonsRects.length === 3) { + for (let i = 0; i < 3; i++) { + const { x, y, w, h, type, enabled } = this.alignButtonsRects[i]; + if (enabled && pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + this.textParams.text_align = type; + this.setDirtyCanvas(true); + if (this.scheduleRender) this.scheduleRender('text_align'); + this.syncAllWidgets(); + return true; + } + } + } + if (this.fontSelectRect) { + const { x, y, w, h } = this.fontSelectRect; + if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + this.showFontSelector(); + return true; + } + } + if (this.hexRect) { + const { x, y, w, h } = this.hexRect; + if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + this.showHexEditor(); + return true; + } + } + if (this.colorBoxRect) { + const { x, y, w, h } = this.colorBoxRect; + if (pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h) { + this.openColorPicker(); + return true; + } + } + if (pos && this.buttonsY !== undefined) { + const btnH = 30; + const btnGap = 10; + const btnW = (this.size[0] - 35) / 2; + const y1 = this.buttonsY; + if (pos[0] >= 15 && pos[0] <= 15 + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH) { + this.sendTransforms(); + return true; + } + if (pos[0] >= 15 + btnW + btnGap && pos[0] <= 15 + btnW + btnGap + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH) { + this.cancelEditing(); + return true; + } + } + + if (!this.advancedMode) return false; + + if (!this.isEditing || this.isLoading || !this.textMask) return; + const { cSize, rectX, rectY } = this.getCanvasMetrics(); + const mx = pos[0], my = pos[1]; + const frozenScale = this.viewScale, frozenOffsetX = this.viewOffsetX, frozenOffsetY = this.viewOffsetY; + const worldMx = (mx - rectX - frozenOffsetX) / frozenScale, worldMy = (my - rectY - frozenOffsetY) / frozenScale; + const screenHandles = this.computeScreenHandles(rectX, rectY, frozenScale, frozenOffsetX, frozenOffsetY); + const cornerSize = 14, edgeSize = 18, rotateSize = 26; + let detectedType = null, minDist = Infinity; + const checkHandle = (name, h, threshold) => { + const dist = Math.hypot(mx - h.x, my - h.y); + if (dist < threshold && dist < minDist) { + detectedType = name; + minDist = dist; + } + }; + for (const [name, hPos] of Object.entries(screenHandles)) { + const isEdge = ['scale-t', 'scale-b', 'scale-l', 'scale-r'].includes(name); + const threshold = name === 'rotate' ? rotateSize : (isEdge ? edgeSize : cornerSize); + checkHandle(name, hPos, threshold); + } + this.dragType = detectedType; + if (this.dragType) { + this.isDragging = true; + this.awaitingServerRender = false; + const startW = this.overlay.width; + const startH = this.overlay.height; + const startRot = this.overlay.rotation * Math.PI / 180; + const startCX = this.overlay.x; + const startCY = this.overlay.y; + const localPoints = { + 'scale-br': { active: [startW/2, startH/2], fixed: [-startW/2, -startH/2] }, + 'scale-bl': { active: [-startW/2, startH/2], fixed: [startW/2, -startH/2] }, + 'scale-tr': { active: [startW/2, -startH/2], fixed: [-startW/2, startH/2] }, + 'scale-tl': { active: [-startW/2, -startH/2], fixed: [startW/2, startH/2] }, + 'scale-r': { active: [startW/2, 0], fixed: [-startW/2, 0] }, + 'scale-l': { active: [-startW/2, 0], fixed: [startW/2, 0] }, + 'scale-b': { active: [0, startH/2], fixed: [0, -startH/2] }, + 'scale-t': { active: [0, -startH/2], fixed: [0, startH/2] } + }; + const pts = localPoints[this.dragType]; + const fixedLocal = pts ? { x: pts.fixed[0], y: pts.fixed[1] } : { x: 0, y: 0 }; + const activeLocal = pts ? { x: pts.active[0], y: pts.active[1] } : { x: 0, y: 0 }; + this.dragState = { + startMouseX: worldMx, startMouseY: worldMy, + startX: this.overlay.x, startY: this.overlay.y, + startW: startW, startH: startH, + startRotation: this.overlay.rotation, + startRot: startRot, + startCX: startCX, + startCY: startCY, + viewScale: frozenScale, viewOffsetX: frozenOffsetX, viewOffsetY: frozenOffsetY, + fixedLocal: fixedLocal, + activeLocal: activeLocal + }; + return true; + } + const halfW = this.overlay.width / 2; + const halfH = this.overlay.height / 2; + const dx = worldMx - this.overlay.x, dy = worldMy - this.overlay.y; + const rotRad = -this.overlay.rotation * Math.PI / 180; + const localX = dx * Math.cos(rotRad) - dy * Math.sin(rotRad), localY = dx * Math.sin(rotRad) + dy * Math.cos(rotRad); + if (Math.abs(localX) < halfW && Math.abs(localY) < halfH) { + this.dragType = 'move'; + this.dragState = { startMouseX: worldMx, startMouseY: worldMy, startX: this.overlay.x, startY: this.overlay.y, startW: this.overlay.width, startH: this.overlay.height, startRotation: this.overlay.rotation, startRot: this.overlay.rotation * Math.PI / 180, viewScale: frozenScale, viewOffsetX: frozenOffsetX, viewOffsetY: frozenOffsetY }; + return true; + } + return false; + }; + + nodeType.prototype.onMouseMove = function(event, pos) { + if (this.lineSpacingDragging && pos) { + this.updateLineSpacingFromMouse(pos[0]); + return; + } + const prevTextareaHover = this.textareaHover; + if (this.textareaRect && !this.textareaState.isOpen) { + const { x, y, w, h } = this.textareaRect; + this.textareaHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } else { + this.textareaHover = false; + } + if (prevTextareaHover !== this.textareaHover) this.setDirtyCanvas(true); + const prevLineSpacingHover = this.lineSpacingHover; + if (this.lineSpacingRect) { + const { x, y, w, h } = this.lineSpacingRect; + this.lineSpacingHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } else { + this.lineSpacingHover = false; + } + if (prevLineSpacingHover !== this.lineSpacingHover) this.setDirtyCanvas(true); + const prevLineSpacingValueHover = this.lineSpacingValueHover || false; + if (this.lineSpacingValueRect) { + const { x, y, w, h } = this.lineSpacingValueRect; + this.lineSpacingValueHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } else { + this.lineSpacingValueHover = false; + } + if (prevLineSpacingValueHover !== this.lineSpacingValueHover) this.setDirtyCanvas(true); + const prevAlignHover = [...this.alignButtonsHover]; + if (this.alignButtonsRects && this.alignButtonsRects.length === 3) { + for (let i = 0; i < 3; i++) { + const { x, y, w, h, enabled } = this.alignButtonsRects[i]; + this.alignButtonsHover[i] = enabled && pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } + } else { + this.alignButtonsHover = [false, false, false]; + } + if (prevAlignHover.some((v, i) => v !== this.alignButtonsHover[i])) this.setDirtyCanvas(true); + const prevFontHover = this.fontSelectHover; + if (this.fontSelectRect) { + const { x, y, w, h } = this.fontSelectRect; + this.fontSelectHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } else { + this.fontSelectHover = false; + } + if (prevFontHover !== this.fontSelectHover) this.setDirtyCanvas(true); + const prevHexHover = this.hexHover; + if (this.hexRect) { + const { x, y, w, h } = this.hexRect; + this.hexHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } else { + this.hexHover = false; + } + if (prevHexHover !== this.hexHover) this.setDirtyCanvas(true); + const prevColorBoxHover = this.colorBoxHover; + if (this.colorBoxRect) { + const { x, y, w, h } = this.colorBoxRect; + this.colorBoxHover = pos[0] >= x && pos[0] <= x + w && pos[1] >= y && pos[1] <= y + h; + } else { + this.colorBoxHover = false; + } + if (prevColorBoxHover !== this.colorBoxHover) this.setDirtyCanvas(true); + const prevBtnApply = this.btnApplyHover; + const prevBtnCancel = this.btnCancelHover; + if (this.buttonsY !== undefined) { + const btnH = 30; + const btnGap = 10; + const btnW = (this.size[0] - 35) / 2; + const y1 = this.buttonsY; + this.btnApplyHover = pos[0] >= 15 && pos[0] <= 15 + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH; + this.btnCancelHover = pos[0] >= 15 + btnW + btnGap && pos[0] <= 15 + btnW + btnGap + btnW && pos[1] >= y1 && pos[1] <= y1 + btnH; + } + if (prevBtnApply !== this.btnApplyHover || prevBtnCancel !== this.btnCancelHover) this.setDirtyCanvas(true); + + if (!this.advancedMode) return; + + if (!this.dragType || !this.isEditing || this.isLoading || !this.dragState) return; + const { rectX, rectY } = this.getCanvasMetrics(); + const mx = pos[0], my = pos[1]; + const worldMx = (mx - rectX - this.dragState.viewOffsetX) / this.dragState.viewScale; + const worldMy = (my - rectY - this.dragState.viewOffsetY) / this.dragState.viewScale; + if (this.dragType === 'move') { + this.overlay.x = this.dragState.startX + (worldMx - this.dragState.startMouseX); + this.overlay.y = this.dragState.startY + (worldMy - this.dragState.startMouseY); + } + else if (this.dragType === 'rotate') { + const cx = this.overlay.x, cy = this.overlay.y; + const sA = Math.atan2(this.dragState.startMouseY - cy, this.dragState.startMouseX - cx); + const cA = Math.atan2(worldMy - cy, worldMx - cx); + this.overlay.rotation = this.dragState.startRotation + (cA - sA) * 180 / Math.PI; + } + else if (this.dragType.startsWith('scale')) { + const { startCX, startCY, startW, startH, startRot, fixedLocal, activeLocal } = this.dragState; + const { x: fx, y: fy } = fixedLocal; + const { x: ax0, y: ay0 } = activeLocal; + const cosR = Math.cos(startRot), sinR = Math.sin(startRot); + const fixedWorldX = startCX + fx*cosR - fy*sinR; + const fixedWorldY = startCY + fx*sinR + fy*cosR; + const dxw = worldMx - fixedWorldX; + const dyw = worldMy - fixedWorldY; + const ldx = dxw*cosR + dyw*sinR; + const ldy = -dxw*sinR + dyw*cosR; + let newW = startW, newH = startH; + const isCorner = (ax0 !== 0 && ay0 !== 0); + if (ax0 !== 0) { + const signX = Math.sign(ax0); + newW = 2 * (ldx + fx) / signX; + newW = Math.max(40, newW); + } + if (ay0 !== 0) { + const signY = Math.sign(ay0); + newH = 2 * (ldy + fy) / signY; + newH = Math.max(40, newH); + } + if (isCorner) { + const scaleX = newW / startW; + const scaleY = newH / startH; + const scale = Math.max(scaleX, scaleY); + newW = startW * scale; + newH = startH * scale; + } + const newCX = fixedWorldX - (fx*cosR - fy*sinR); + const newCY = fixedWorldY - (fx*sinR + fy*cosR); + this.overlay.x = newCX; + this.overlay.y = newCY; + this.overlay.width = newW; + this.overlay.height = newH; + this.textParams.text_scale_x = this.overlay.width / this.baseWidth; + this.textParams.text_scale_y = this.overlay.height / this.baseHeight; + } + this.setDirtyCanvas(true); + }; + + nodeType.prototype.onMouseUp = function() { + if (this.lineSpacingDragging) { + this.lineSpacingDragging = false; + this.setDirtyCanvas(true); + } + if (!this.advancedMode) { + this.dragType = null; + this.dragState = null; + this.setDirtyCanvas(true); + return; + } + if (this.dragType) { + const wasScale = this.dragType.startsWith('scale'); + const wasRotate = this.dragType === 'rotate'; + if (wasScale) { + this.awaitingServerRender = true; + } + this.updateRelativeFromAbsolute(); + if (!this.advancedMode) { + this.computeAndApplyView(); + } + if (wasScale || wasRotate) { + if (this.currentRenderAbortController) { + this.currentRenderAbortController.abort(); + this.currentRenderAbortController = null; + } + if (this.renderTimeout) clearTimeout(this.renderTimeout); + this.currentRenderAbortController = new AbortController(); + const signal = this.currentRenderAbortController.signal; + this.renderMasks(signal); + } + } + this.isDragging = false; + this.dragType = null; + this.dragState = null; + }; + } +}); \ No newline at end of file diff --git a/web/rs_text_overlay.js b/web/rs_text_overlay.js deleted file mode 100644 index be6da37..0000000 --- a/web/rs_text_overlay.js +++ /dev/null @@ -1,424 +0,0 @@ -import { app } from "../../scripts/app.js"; - -app.registerExtension({ - name: "RaykoTextOverlay", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "RS_TextOverlay") { - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function() { - const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; - - this.data = { - 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, - enable_shadow: false, - shadow_color: "#333333", - shadow_offset_x: 10, - shadow_offset_y: 10, - shadow_blur: 0.2, - shadow_opacity: 0.8, - enable_glow: false, - glow_color: "#FFFFFF", - glow_size: 150, - glow_spread: 250, - glow_brightness: 1.0, - font_list: ["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; - this.hiddenWidget.serializeValue = () => { - this.syncData(); - return this.hiddenWidget.value; - }; - try { - const saved = JSON.parse(this.hiddenWidget.value); - if (saved && typeof saved === 'object') { - this.data = { ...this.data, ...saved }; - } - } catch (e) { - console.error("Error loading saved data", e); - } - } - - if (this.widgets) { - this.widgets.forEach(w => w.hidden = true); - } - - this.setSize([this.targetWidth, 750]); - const self = this; - - this.drawSeparator = function(ctx, text, x, y, w, h) { - ctx.fillStyle = "#444"; - ctx.fillRect(x, y + 8, w, 1); - ctx.fillStyle = "#888"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(text, x + w/2, y + 22); - }; - - this.onDrawForeground = function(ctx, visibleRect) { - this.clickZones = []; - const startY = 80; - const rowH = this.rowHeight; - const pad = this.padding; - const labelW = this.labelWidth; - const arrowW = 25; - const inputW = this.size[0] - pad*2 - labelW; - let y = startY; - - 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; - - 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; - - 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; - - 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; - this.clickZones.push({ type: "number", field: "outline_thickness", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 50 }); - this.clickZones.push({ type: "arrow_left", field: "outline_thickness", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 50, step: 1 }); - 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; - - 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; - - this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH); - y += rowH + 5; - - 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; - - 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; - this.clickZones.push({ type: "number", field: "text_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: "text_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: "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; - - 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; - this.clickZones.push({ type: "number", field: "line_spacing", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0.5, max: 3, float: true }); - this.clickZones.push({ type: "arrow_left", field: "line_spacing", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0.5, max: 3, step: 0.1, float: true }); - 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; - - 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; - this.clickZones.push({ type: "number", field: "letter_spacing", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -20, max: 100, float: true }); - this.clickZones.push({ type: "arrow_left", field: "letter_spacing", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -20, max: 100, step: 0.5, float: true }); - 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; - - 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; - - this.drawSeparator(ctx, "SHADOW SETTINGS", pad, y, inputW + labelW, rowH); - y += rowH + 5; - - 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; - - 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; - - 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: -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; - - 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: -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; - - 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; - this.clickZones.push({ type: "number", field: "shadow_blur", 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_blur", 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_blur", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); - y += rowH + 5; - - 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 }); - 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 + 5; - - this.drawSeparator(ctx, "QUALITY SETTINGS", pad, y, inputW + labelW, rowH); - y += rowH + 5; - - 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; - - 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; - } - - 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) { - this.setSize([this.targetWidth, totalH]); - } - }; - - 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 === "number") { - 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") { - let current = self.data[zone.field]; - current = zone.float ? current - zone.step : current - zone.step; - current = zone.float ? Math.round(current * 100) / 100 : current; - self.data[zone.field] = Math.max(zone.min, current); - self.updateUI(); - return true; - } - if (zone.type === "arrow_right") { - let current = self.data[zone.field]; - current = zone.float ? current + zone.step : current + zone.step; - current = zone.float ? Math.round(current * 100) / 100 : current; - self.data[zone.field] = Math.min(zone.max, current); - self.updateUI(); - return true; - } - } - } - return false; - }; - - this.drawLabel = function(ctx, text, x, y, w, h) { - 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'), 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) + "..."; - ctx.fillText(line, x + 5, startY + (i * 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.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(); - }; - 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, 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"; - 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, 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); - }; - - this.showComboSelector = function(fieldName, clickEvent) { - 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;`; - 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(); }; - 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"; } - document.body.appendChild(menu); - setTimeout(() => { const closeHandler = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 100); - }; - 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'; } - 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(); } }; - }; - this.showInlineInput = function(fieldName, clickEvent, options = {}) { - const currentValue = self.data[fieldName] || ''; - 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 = '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(); }; - 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); - 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'; - 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; }; - return result; - }; - } - } -}); \ No newline at end of file