diff --git a/Rayko_Intermediate_Spline_Mask.py b/Rayko_Intermediate_Spline_Mask.py index b1710a8..fef06c1 100644 --- a/Rayko_Intermediate_Spline_Mask.py +++ b/Rayko_Intermediate_Spline_Mask.py @@ -54,8 +54,6 @@ class RaykoIntermediateSplineMask: else: PENDING_DECISIONS[unique_id]["coordinates"] = coordinates - print(f"[InSPLINE 🦊] Node {unique_id} waiting for approval...") - if prompt_id is None: import uuid prompt_id = str(uuid.uuid4()) @@ -84,14 +82,12 @@ class RaykoIntermediateSplineMask: current_coordinates = state.get("coordinates", coordinates) if current_status == "approved": - print(f"[InSPLINE 🦊] Node {unique_id} APPROVED!") coordinates = current_coordinates if unique_id in PENDING_DECISIONS: del PENDING_DECISIONS[unique_id] break if current_status == "cancelled": - print(f"[InSPLINE 🦊] Node {unique_id} CANCELLED!") if unique_id in PENDING_DECISIONS: del PENDING_DECISIONS[unique_id] break @@ -105,14 +101,11 @@ class RaykoIntermediateSplineMask: if current_status == "rejected": PENDING_DECISIONS[unique_id]["status"] = "pending" coordinates = current_coordinates - print(f"[InSPLINE 🦊] Node {unique_id} rejected, waiting for new points...") time.sleep(0.3) else: print("[InSPLINE 🦊] Server not available, skipping pause logic.") - print(f"[InSPLINE 🦊] Processing mask with coordinates: {coordinates[:80]}...") - if image is None or len(image) == 0: h, w = 512, 512 return (torch.zeros((1, h, w, 3)), torch.zeros((1, h, w))) @@ -134,11 +127,11 @@ class RaykoIntermediateSplineMask: draw.polygon(pts, fill=255) mask_np = np.array(pil_mask).astype(np.float32) / 255.0 - print(f"[InSPLINE 🦊] ✅ Mask created with {len(pts)} points") + print(f"[InSPLINE 🦊] Mask created with {len(pts)} points") else: - print(f"[InSPLINE 🦊] ⚠️ Less than 3 points - empty mask") + print(f"[InSPLINE 🦊] Less than 3 points - empty mask") except Exception as e: - print(f"[InSPLINE 🦊] ❌ Error parsing coordinates: {e}") + print(f"[InSPLINE 🦊] Error parsing coordinates: {e}") mask_tensor = torch.from_numpy(mask_np).unsqueeze(0) return (image, mask_tensor) @@ -153,28 +146,22 @@ if SERVER_AVAILABLE: decision = data.get("decision") coordinates = data.get("coordinates") - print(f"[InSPLINE 🦊] API received '{decision}' for node '{node_id}'") - if node_id in PENDING_DECISIONS: if decision == "approve": PENDING_DECISIONS[node_id]["status"] = "approved" if coordinates: PENDING_DECISIONS[node_id]["coordinates"] = coordinates - print(f"[InSPLINE 🦊] Status: APPROVED") elif decision == "reject": PENDING_DECISIONS[node_id]["status"] = "rejected" if coordinates is not None: PENDING_DECISIONS[node_id]["coordinates"] = coordinates - print(f"[InSPLINE 🦊] Status: REJECTED (node still waiting)") elif decision == "cancel": PENDING_DECISIONS[node_id]["status"] = "cancelled" - print(f"[InSPLINE 🦊] Status: CANCELLED") - + return web.Response(status=200, text="Decision recorded") else: - print(f"[InSPLINE 🦊] Node {node_id} NOT FOUND in PENDING_DECISIONS") return web.Response(status=404, text=f"Node {node_id} not waiting") except Exception as e: @@ -189,14 +176,10 @@ if SERVER_AVAILABLE: data = await request.json() node_id = str(data.get("node_id")) - print(f"[InSPLINE 🦊] API received CLEANUP for node '{node_id}'") - if node_id in PENDING_DECISIONS: PENDING_DECISIONS[node_id]["status"] = "removed" - print(f"[InSPLINE 🦊] Status: REMOVED") return web.Response(status=200, text="Cleanup recorded") else: - print(f"[InSPLINE 🦊] Node {node_id} NOT FOUND (already cleaned)") return web.Response(status=200, text="Node not found") except Exception as e: diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py new file mode 100644 index 0000000..ab72dd4 --- /dev/null +++ b/Rayko_Text_Overlay.py @@ -0,0 +1,479 @@ +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 +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 + return DummyPBar() + +try: + import folder_paths + HAS_FOLDER_PATHS = True +except ImportError: + HAS_FOLDER_PATHS = False + +print("\033[93m🦊\033[0m \033[93mRaykoStudio - RS Text Overlay \033[92mLOADED\033[0m") + +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 + text_orientation: str + enable_shadow: bool + shadow_color: Tuple[int, int, int, int] + shadow_offset: Tuple[int, int] + shadow_blur: float + text_opacity: int = 100 + + @property + def has_outline(self) -> bool: + return self.outline_thickness > 0 + + @property + def has_shadow(self) -> bool: + return self.enable_shadow and any(self.shadow_offset) + +class RS_TextOverlay: + WEB_DIRECTORY = "web" + _font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {} + _color_cache: Dict[str, Tuple[int, int, int, int]] = {} + _text_dim_cache: Dict[Tuple[str, str, float, float, float, int, str], Tuple[float, float, List[float], List[float]]] = {} + _contour_cache: Dict[int, float] = {} + _font_list: List[str] = None + + @classmethod + def INPUT_TYPES(cls): + current_dir = os.path.dirname(os.path.abspath(__file__)) + fonts_dir = os.path.join(current_dir, "fonts") + font_list = [] + default_font_path = os.path.join(fonts_dir, "Arial.ttf") + if os.path.isfile(default_font_path): + font_list.append("default") + if os.path.isdir(fonts_dir): + for f in sorted(os.listdir(fonts_dir)): + if f.lower().endswith(('.ttf', '.otf', '.ttc')): + if f not in font_list: + font_list.append(f) + if HAS_FOLDER_PATHS: + try: + system_font_dirs = folder_paths.get_folder_paths("fonts") + if system_font_dirs: + for font_dir in system_font_dirs: + if os.path.isdir(font_dir): + for f in sorted(os.listdir(font_dir)): + if f.lower().endswith(('.ttf', '.otf', '.ttc')) and f not in font_list: + font_list.append(f) + except: + pass + cls._font_list = font_list + return { + "required": { + "image": ("IMAGE",), + "mask": ("MASK",), + "node_data": ("STRING", { + "default": json.dumps({ + "text": "Текст", + "font_name": "default", + "text_color": "#FFFFFF", + "outline_thickness": 0, + "outline_color": "#808080", + "rotate_with_mask": True, + "text_opacity": 1.0, + "line_spacing": 1.0, + "letter_spacing": 0.0, + "text_orientation": "horizontal", + "enable_shadow": False, + "shadow_color": "#333333", + "shadow_offset_x": 2, + "shadow_offset_y": 2, + "shadow_blur": 0.0, + "shadow_opacity": 0.8, + "font_list": font_list + }), + "hidden": True + }), + }, + "optional": {} + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_text" + CATEGORY = "🦊 RaykoStudio" + + def __init__(self): + self.debug = False + + def log(self, message: str, level: int = 1): + if self.debug or level <= 0: + prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•") + print(f"{prefix} RS_TextOverlay: {message}") + + def get_font_path(self, font_name: str) -> Optional[str]: + if font_name == "default": + current_dir = os.path.dirname(os.path.abspath(__file__)) + default_path = os.path.join(current_dir, "fonts", "Arial.ttf") + if os.path.isfile(default_path): + return default_path + return None + current_dir = os.path.dirname(os.path.abspath(__file__)) + local_path = os.path.join(current_dir, "fonts", font_name) + if os.path.isfile(local_path): + self.log(f"Font found locally: {local_path}", level=2) + return local_path + if HAS_FOLDER_PATHS: + try: + system_font_dirs = folder_paths.get_folder_paths("fonts") + if system_font_dirs: + for font_dir in system_font_dirs: + system_path = os.path.join(font_dir, font_name) + if os.path.isfile(system_path): + self.log(f"Font found in system: {system_path}", level=2) + return system_path + except: + pass + if os.path.isabs(font_name) and os.path.isfile(font_name): + return font_name + self.log(f"Font not found: {font_name}", level=0) + return None + + @functools.lru_cache(maxsize=32) + 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) + 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)) + cache_key = (str(color), alpha) + if cache_key in self._color_cache: + return self._color_cache[cache_key] + try: + if isinstance(color, str): + color_str = color.strip() + hex_val = color_str[1:] if color_str.startswith('#') else color_str + if len(hex_val) == 3: + r, g, b = int(hex_val[0]*2, 16), int(hex_val[1]*2, 16), int(hex_val[2]*2, 16) + a = int(alpha * 255) + elif len(hex_val) >= 6: + r, g, b = int(hex_val[0:2], 16), int(hex_val[2:4], 16), int(hex_val[4:6], 16) + a = int(hex_val[6:8], 16) if len(hex_val) >= 8 else int(alpha * 255) + else: + r, g, b, a = 255, 255, 255, int(alpha * 255) + result = (r, g, b, a) + else: + result = (255, 255, 255, int(alpha * 255)) + self._color_cache[cache_key] = result + return result + except: + return (255, 255, 255, int(alpha * 255)) + + @functools.lru_cache(maxsize=64) + def calculate_text_dimensions(self, font_key: str, text: str, letter_spacing: float, + line_spacing: float, outline_thickness: int, + text_orientation: str) -> Tuple[float, float, List[float], List[float]]: + cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation) + if cache_key in self._text_dim_cache: + return self._text_dim_cache[cache_key] + temp_img = Image.new("RGB", (1, 1)) + draw = ImageDraw.Draw(temp_img) + font_path, size_str = font_key.rsplit(':', 1) + font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default() + lines = text.split('\n') if text_orientation == "horizontal" else [text.replace('\n', '')] + line_heights, line_widths = [], [] + for line in lines: + line = line.replace('\t', ' ') + if not line: + line_heights.append(0) + line_widths.append(0) + continue + if text_orientation == "horizontal": + bbox = draw.textbbox((0, 0), line, font=font) + line_width = draw.textlength(line, font=font) + letter_spacing * max(0, len(line) - 1) + line_height = (bbox[3] - bbox[1]) * line_spacing + else: + if line: + char_bbox = draw.textbbox((0, 0), line[0], font=font) + line_width = draw.textlength(line[0], font=font) + line_height = (char_bbox[3] - char_bbox[1] + letter_spacing) * len(line) + else: + line_width = line_height = 0 + line_heights.append(line_height + outline_thickness * 2) + line_widths.append(line_width + outline_thickness * 2) + result = (sum(line_heights), max(line_widths) if line_widths else 0, line_heights, line_widths) + self._text_dim_cache[cache_key] = result + return result + + def text_fits(self, font_key: str, text: str, max_width: float, max_height: float, + letter_spacing: float, line_spacing: float, outline_thickness: int, + text_orientation: str) -> bool: + total_height, max_line_width, _, _ = self.calculate_text_dimensions( + font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation + ) + 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]: + font_key = lambda size: f"{font_path or 'default'}:{size}" + if self.text_fits(font_key(max_size), text, max_width, max_height, + config.letter_spacing, config.line_spacing, + config.outline_thickness, config.text_orientation): + return self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default(), max_size + low, high = min_size, max_size + best_size, best_font = min_size, None + while low <= high: + mid = (low + high) // 2 + if pbar: pbar.update(1) + try: + font = self.get_cached_font(font_path, mid) if font_path else ImageFont.load_default() + except: + high = mid - 1 + continue + if self.text_fits(font_key(mid), text, max_width, max_height, + config.letter_spacing, config.line_spacing, + config.outline_thickness, config.text_orientation): + best_font, best_size = font, mid + low = mid + 1 + else: + high = mid - 1 + if best_font is None: + best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default() + return best_font, best_size + + def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray: + 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) + return mask_np + + def calculate_mask_rotation(self, mask_np: np.ndarray) -> float: + mask_hash = hash(mask_np.tobytes()) + if mask_hash in self._contour_cache: + return self._contour_cache[mask_hash] + try: + contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return 0.0 + contour = max(contours, key=cv2.contourArea) + if cv2.contourArea(contour) < 10: + 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: + return 0.0 + + def _draw_single_line(self, draw, position, text, font, color, letter_spacing, text_orientation): + x, y = position + if text_orientation == "horizontal": + current_x = x + for char in text: + draw.text((current_x, y), char, font=font, fill=color) + current_x += draw.textlength(char, font=font) + letter_spacing + else: + current_y = y + for char in text: + draw.text((x, current_y), char, font=font, fill=color) + bbox = draw.textbbox((x, current_y), char, font=font) + current_y += (bbox[3] - bbox[1]) + letter_spacing + + def render_text_layer(self, text, config, container_size): + text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(text_layer) + total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions( + f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}", + text, config.letter_spacing, config.line_spacing, + config.outline_thickness, config.text_orientation + ) + start_y = (container_size[1] - total_h) // 2 + current_y = start_y + lines = text.split('\n') if config.text_orientation == "horizontal" else [text.replace('\n', '')] + for line, line_w, line_h in zip(lines, line_widths, line_heights): + if not line.strip(): + current_y += line_h + continue + start_x = (container_size[0] - line_w) // 2 + if config.has_outline: + thickness = config.outline_thickness + steps = max(2, thickness // 2) + for dx in range(-thickness, thickness + 1, steps): + for dy in range(-thickness, thickness + 1, steps): + if dx == 0 and dy == 0: continue + self._draw_single_line(draw, (start_x + dx, current_y + dy), + line, config.font, config.outline_color, + config.letter_spacing, config.text_orientation) + self._draw_single_line(draw, (start_x, current_y), line, config.font, + config.text_color, config.letter_spacing, config.text_orientation) + current_y += line_h + if config.has_shadow: + text_layer = self._apply_shadow_effect(text_layer, config) + return text_layer + + def _apply_shadow_effect(self, text_layer, config): + alpha = text_layer.getchannel("A") + shadow = Image.new("RGBA", text_layer.size, config.shadow_color) + shadow.putalpha(alpha) + if config.shadow_blur > 0: + blur_radius = int(config.shadow_blur * 20) + if blur_radius > 0: + shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius)) + offset_x, offset_y = config.shadow_offset + if offset_x or offset_y: + shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), Image.BICUBIC, fill=(0, 0, 0, 0)) + result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0)) + result.paste(shadow, (0, 0), shadow) + result.paste(text_layer, (0, 0), text_layer) + return result + + def _process_single(self, image, mask, text, font_name, config, padding, vertical_align, horizontal_align, + rotate_with_mask, min_font_size, max_font_size): + image_np = (image.cpu().numpy() * 255).astype(np.uint8) + image_pil = Image.fromarray(image_np, 'RGB') if image_np.ndim == 3 else Image.fromarray(image_np[0], 'RGB') + mask_np = self._prepare_mask_for_opencv(mask) + mask_pil = Image.fromarray(mask_np, 'L') + 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) + pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None + config.font, actual_size = self.find_optimal_font_size(font_path, text, available_w, available_h, min_font_size, max_font_size, config, pbar) + angle = self.calculate_mask_rotation(mask_np) if rotate_with_mask else 0 + diag = math.sqrt(available_w**2 + available_h**2) + temp_size = int(diag * 1.2) + 50 + text_layer = self.render_text_layer(text, config, (temp_size, temp_size)) + if rotate_with_mask and abs(angle) > 0.1: + text_layer = text_layer.rotate(angle, center=(temp_size//2, temp_size//2), resample=Image.BICUBIC, expand=False) + rot_bbox = text_layer.getbbox() + if not rot_bbox: + return image + rx1, ry1, rx2, ry2 = rot_bbox + text_w, text_h = rx2 - rx1, ry2 - ry1 + if rotate_with_mask: + paste_x = int((x1 + x2 - text_w) / 2) + paste_y = int((y1 + y2 - text_h) / 2) + else: + paste_x = {'left': x1 + padding, 'right': x2 - text_w - padding, 'center': int(x1 + (available_w - text_w) / 2 + padding)}[horizontal_align] + paste_y = {'top': y1 + padding, 'bottom': y2 - text_h - padding, 'center': int(y1 + (available_h - text_h) / 2 + padding)}[vertical_align] + cropped = text_layer.crop(rot_bbox) + image_rgba = image_pil.convert("RGBA") + overlay = Image.new("RGBA", image_rgba.size, (0, 0, 0, 0)) + overlay.paste(cropped, (paste_x, paste_y), cropped) + result = Image.alpha_composite(image_rgba, overlay) + result_np = np.array(result.convert("RGB")).astype(np.float32) / 255.0 + return torch.from_numpy(result_np).unsqueeze(0) + + def apply_text(self, image: torch.Tensor, mask: torch.Tensor, node_data: str = None, **kwargs) -> Tuple[torch.Tensor]: + try: + if image is None or image.numel() == 0: + raise ValueError("Input image is empty") + if mask is None or mask.numel() == 0: + raise ValueError("Input mask is empty") + data = {} + if node_data: + try: + data = json.loads(node_data) + except: + pass + text = data.get('text', 'Текст') + font_name = data.get('font_name', 'default') + text_color = data.get('text_color', '#FFFFFF') + outline_thickness = data.get('outline_thickness', 0) + outline_color = data.get('outline_color', '#808080') + rotate_with_mask = data.get('rotate_with_mask', True) + text_opacity = data.get('text_opacity', 1.0) + line_spacing = data.get('line_spacing', 1.0) + letter_spacing = data.get('letter_spacing', 0.0) + text_orientation = data.get('text_orientation', 'horizontal') + enable_shadow = data.get('enable_shadow', False) + shadow_color = data.get('shadow_color', '#333333') + shadow_offset_x = data.get('shadow_offset_x', 2) + shadow_offset_y = data.get('shadow_offset_y', 2) + shadow_blur = data.get('shadow_blur', 0.0) + shadow_opacity = data.get('shadow_opacity', 0.8) + config = TextRenderConfig( + font=None, + text_color=self.parse_color(text_color, text_opacity), + outline_color=self.parse_color(outline_color, text_opacity), + outline_thickness=outline_thickness, + letter_spacing=letter_spacing, + line_spacing=line_spacing, + text_orientation=text_orientation, + enable_shadow=enable_shadow, + shadow_color=self.parse_color(shadow_color, shadow_opacity), + shadow_offset=(shadow_offset_x, shadow_offset_y), + shadow_blur=shadow_blur, + text_opacity=int(text_opacity * 100) + ) + if image.ndim == 4: + results = [] + for i in range(image.shape[0]): + result = self._process_single(image[i], mask[i] if mask.ndim == 4 else mask, text, font_name, + config, 5, 'center', 'center', rotate_with_mask, 8, 500) + results.append(result) + return (torch.cat(results, dim=0),) + else: + result = self._process_single(image, mask, text, font_name, config, 5, + 'center', 'center', rotate_with_mask, 8, 500) + return (result,) + except Exception as e: + self.log(f"Error: {type(e).__name__}: {e}", level=0) + import traceback + traceback.print_exc() + return (image,) + +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/__init__.py b/__init__.py index c350a43..59fb056 100644 --- a/__init__.py +++ b/__init__.py @@ -18,18 +18,18 @@ PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" def load_modules(): try: current_dir = Path(__file__).parent - logger.debug(f"Сканируем директорию: {current_dir}") + logger.debug(f"Scanning the directory: {current_dir}") loaded_modules = [] failed_modules = [] for py_file in current_dir.glob('*.py'): filename = py_file.name if filename in IGNORED_MODULES: - logger.debug(f"Пропускаем игнорируемый файл: {filename}") + logger.debug(f"Skipping the ignored file: {filename}") continue module_name = py_file.stem - logger.debug(f"Попытка загрузки модуля: {module_name}") + logger.debug(f"Module loading attempt: {module_name}") try: module = importlib.import_module(f'.{module_name}', package=__name__) @@ -37,24 +37,24 @@ def load_modules(): loaded_modules.append(module_name) except ImportError as e: failed_modules.append((module_name, str(e))) - logger.error(f"Ошибка импорта модуля {module_name}: {e}") + logger.error(f"Module import error {module_name}: {e}") except Exception as e: failed_modules.append((module_name, str(e))) - logger.error(f"Неожиданная ошибка в модуле {module_name}: {e}") + logger.error(f"Unexpected error in the module {module_name}: {e}") missing_required = REQUIRED_MODULES - set(loaded_modules) if missing_required: - logger.error(f"Обязательные модули не загружены: {missing_required}") + logger.error(f"Required modules are not loaded: {missing_required}") if not failed_modules: - logger.info(f"Все модули {PACKAGE_DISPLAY_NAME} успешно загружены.") + logger.info(f"✅ All modules 🦊 {PACKAGE_DISPLAY_NAME} loaded successfully") else: for mod_name, error in failed_modules: - logger.error(f"Модуль '{mod_name}' не загружен: {error}") - logger.warning(f"Загружено {len(loaded_modules)} модулей, ошибки в {len(failed_modules)} модулях.") + logger.error(f"The module '{mod_name}' is not loaded: {error}") + logger.warning(f"Loaded {len(loaded_modules)} modules, errors in {len(failed_modules)} modules") except Exception as e: - logger.critical(f"Критическая ошибка при загрузке модулей: {e}") + logger.critical(f"Critical error when loading modules: {e}") raise def _merge_module_mappings(module, module_name: str): @@ -63,39 +63,39 @@ def _merge_module_mappings(module, module_name: str): new_classes = module.NODE_CLASS_MAPPINGS duplicates = set(NODE_CLASS_MAPPINGS.keys()) & set(new_classes.keys()) if duplicates: - logger.warning(f"Найдены дубликаты классов в модуле {module_name}: {duplicates}") + logger.warning(f"Duplicate classes found in the module {module_name}: {duplicates}") NODE_CLASS_MAPPINGS.update(new_classes) - logger.debug(f"Добавлены классы из {module_name}: {list(new_classes.keys())}") + logger.debug(f"Added classes from: {list(new_classes.keys())}") if hasattr(module, 'NODE_DISPLAY_NAME_MAPPINGS'): new_names = module.NODE_DISPLAY_NAME_MAPPINGS duplicates = set(NODE_DISPLAY_NAME_MAPPINGS.keys()) & set(new_names.keys()) if duplicates: - logger.warning(f"Найдены дубликаты имен в модуле {module_name}: {duplicates}") + logger.warning(f"Duplicate names found in the module {module_name}: {duplicates}") NODE_DISPLAY_NAME_MAPPINGS.update(new_names) - logger.debug(f"Добавлены имена из {module_name}: {list(new_names.keys())}") + logger.debug(f"Added names from {module_name}: {list(new_names.keys())}") except Exception as e: - logger.error(f"Ошибка при объединении маппингов из модуля {module_name}: {e}") + logger.error(f"Error when combining mappings from the module {module_name}: {e}") raise try: load_modules() except Exception as e: - logger.critical(f"Фатальная ошибка при инициализации пакета: {e}") + logger.critical(f"Fatal error during package initialization: {e}") pass __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] if not NODE_CLASS_MAPPINGS: - logger.warning("NODE_CLASS_MAPPINGS пуст - возможно, не найдено модулей с узлами") + logger.warning("NODE_CLASS_MAPPINGS is empty - perhaps no modules with nodes were found") else: - logger.debug(f"Загружено {len(NODE_CLASS_MAPPINGS)} классов узлов") + logger.debug(f"Uploaded by {len(NODE_CLASS_MAPPINGS)} node classes") if not NODE_DISPLAY_NAME_MAPPINGS: - logger.debug("NODE_DISPLAY_NAME_MAPPINGS пуст - будут использованы имена классов по умолчанию") + logger.debug("NODE_DISPLAY_NAME_MAPPINGS is empty - the default class names will be used") else: - logger.debug(f"Загружено {len(NODE_DISPLAY_NAME_MAPPINGS)} отображаемых имен") + logger.debug(f"Loaded {len(NODE_DISPLAY_NAME_MAPPINGS)} display names") WEB_DIRECTORY = "./web" __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] diff --git a/fonts/Yiggivoo Unicode.regular.ttf b/fonts/Yiggivoo Unicode Regular.ttf similarity index 100% rename from fonts/Yiggivoo Unicode.regular.ttf rename to fonts/Yiggivoo Unicode Regular.ttf diff --git a/package.json b/package.json index c043076..d7d0da5 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ }, "comfyui": { "nodes": [ - "Rayko_Image_Text.py" - "Rayko_Save_Image_Pair.py" - "Rayko_Saturation.py" + "Rayko_Intermediate_Spline_Mask.py" "Rayko_Spline_Mask.py" "Rayko_Models_Loader.py" + "Rayko_Saturation.py" "Rayko_Save_Image.py" + "Rayko_Save_Image_Pair.py" + "Rayko_Image_Text.py" + "Rayko_Text_Overlay.py" ] } } diff --git a/web/rs_text_overlay.js b/web/rs_text_overlay.js new file mode 100644 index 0000000..e1a1b86 --- /dev/null +++ b/web/rs_text_overlay.js @@ -0,0 +1,603 @@ +import { app } from "../../scripts/app.js"; +app.registerExtension({ + name: "RaykoTextOverlay", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "RS_TextOverlay") { + console.log("🦊 [RS_TextOverlay] JS loaded!"); + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + this.data = { + text: "Текст", + font_name: "Arial.ttf", + text_color: "#FFFFFF", + outline_thickness: 0, + outline_color: "#808080", + rotate_with_mask: true, + text_opacity: 1.0, + line_spacing: 1.0, + letter_spacing: 0.0, + text_orientation: "horizontal", + enable_shadow: false, + shadow_color: "#333333", + shadow_offset_x: 2, + shadow_offset_y: 2, + shadow_blur: 0.0, + shadow_opacity: 0.8, + font_list: ["default"] + }; + 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("🦊 [RS_TextOverlay] Error loading saved ", e); + } + } + if (this.widgets) { + this.widgets.forEach(w => w.hidden = true); + } + this.setSize([this.targetWidth, 650]); + 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; + + // === TEXT === + this.drawLabel(ctx, "TEXT", pad, y, labelW, rowH * 2); + this.drawMultilineField(ctx, this.data.text, pad + labelW, y, inputW, rowH * 2); + this.clickZones.push({ type: "text", x: pad + labelW, y: y, w: inputW, h: rowH * 2 }); + y += rowH * 2 + 5; + + // === FONT_NAME === + this.drawLabel(ctx, "FONT", pad, y, labelW, rowH); + this.drawComboField(ctx, this.data.font_name, pad + labelW, y, inputW, rowH); + this.widgetYPositions["font_name"] = y; + this.clickZones.push({ type: "combo", field: "font_name", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === TEXT_COLOR === + this.drawLabel(ctx, "TEXT COLOR", pad, y, labelW, rowH); + this.drawColorField(ctx, this.data.text_color, pad + labelW, y, inputW, rowH); + this.widgetYPositions["text_color"] = y; + this.clickZones.push({ type: "color", field: "text_color", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === OUTLINE_THICKNESS === + this.drawLabel(ctx, "OUTLINE THICK", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.outline_thickness, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["outline_thickness"] = y; + 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; + + // === OUTLINE_COLOR === + this.drawLabel(ctx, "OUTLINE COLOR", pad, y, labelW, rowH); + this.drawColorField(ctx, this.data.outline_color, pad + labelW, y, inputW, rowH); + this.widgetYPositions["outline_color"] = y; + this.clickZones.push({ type: "color", field: "outline_color", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === FORMATTING TEXT === + this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH); + y += rowH + 5; + + // === ROTATE_WITH_MASK === + this.drawLabel(ctx, "ROTATE WITH MASK", pad, y, labelW, rowH); + this.drawBooleanField(ctx, this.data.rotate_with_mask, pad + labelW, y, inputW, rowH); + this.widgetYPositions["rotate_with_mask"] = y; + this.clickZones.push({ type: "boolean", field: "rotate_with_mask", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === TEXT_OPACITY === + this.drawLabel(ctx, "TEXT OPACITY", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.text_opacity, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["text_opacity"] = y; + 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; + + // === LINE_SPACING === + this.drawLabel(ctx, "LINE SPACING", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.line_spacing, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["line_spacing"] = y; + 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; + + // === LETTER_SPACING === + this.drawLabel(ctx, "LETTER SPACING", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.letter_spacing, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["letter_spacing"] = y; + 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; + + // === TEXT_ORIENTATION === + this.drawLabel(ctx, "ORIENTATION", pad, y, labelW, rowH); + this.drawComboField(ctx, this.data.text_orientation, pad + labelW, y, inputW, rowH); + this.widgetYPositions["text_orientation"] = y; + this.clickZones.push({ type: "combo", field: "text_orientation", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === SETTING SHADOW === + this.drawSeparator(ctx, "SETTING SHADOW", pad, y, inputW + labelW, rowH); + y += rowH + 5; + + // === ENABLE_SHADOW === + this.drawLabel(ctx, "ENABLE SHADOW", pad, y, labelW, rowH); + this.drawBooleanField(ctx, this.data.enable_shadow, pad + labelW, y, inputW, rowH); + this.widgetYPositions["enable_shadow"] = y; + this.clickZones.push({ type: "boolean", field: "enable_shadow", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === SHADOW_COLOR === + this.drawLabel(ctx, "SHADOW COLOR", pad, y, labelW, rowH); + this.drawColorField(ctx, this.data.shadow_color, pad + labelW, y, inputW, rowH); + this.widgetYPositions["shadow_color"] = y; + this.clickZones.push({ type: "color", field: "shadow_color", x: pad + labelW, y: y, w: inputW, h: rowH }); + y += rowH + 5; + + // === SHADOW_OFFSET_X === + this.drawLabel(ctx, "SHADOW OFFSET X", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_x, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["shadow_offset_x"] = y; + this.clickZones.push({ type: "number", field: "shadow_offset_x", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -50, max: 50 }); + this.clickZones.push({ type: "arrow_left", field: "shadow_offset_x", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); + y += rowH + 5; + + // === SHADOW_OFFSET_Y === + this.drawLabel(ctx, "SHADOW OFFSET Y", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.shadow_offset_y, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["shadow_offset_y"] = y; + this.clickZones.push({ type: "number", field: "shadow_offset_y", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -50, max: 50 }); + this.clickZones.push({ type: "arrow_left", field: "shadow_offset_y", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); + this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -50, max: 50, step: 1 }); + y += rowH + 5; + + // === SHADOW_BLUR === + this.drawLabel(ctx, "SHADOW BLUR", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.shadow_blur, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["shadow_blur"] = y; + 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; + + // === SHADOW_OPACITY === + this.drawLabel(ctx, "SHADOW OPACITY", pad, y, labelW, rowH); + this.drawNumberFieldWithArrows(ctx, this.data.shadow_opacity, pad + labelW, y, inputW, rowH, arrowW); + this.widgetYPositions["shadow_opacity"] = y; + this.clickZones.push({ type: "number", field: "shadow_opacity", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 1, float: true }); + this.clickZones.push({ type: "arrow_left", field: "shadow_opacity", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); + this.clickZones.push({ type: "arrow_right", field: "shadow_opacity", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.05, float: true }); + y += rowH + 15; + + 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(); + return true; + } + if (zone.type === "combo") { + console.log("🔽 Combo clicked:", zone.field); + self.showComboSelector(zone.field); + return true; + } + if (zone.type === "color") { + console.log("🎨 Color clicked:", zone.field); + self.openColorPicker(zone.field); + return true; + } + if (zone.type === "boolean") { + self.data[zone.field] = !self.data[zone.field]; + self.updateUI(); + return true; + } + if (zone.type === "number") { + const current = self.data[zone.field]; + const newVal = prompt(`${zone.field}:`, current); + if (newVal !== null) { + let parsed = zone.float ? parseFloat(newVal) : parseInt(newVal); + if (!isNaN(parsed)) { + parsed = Math.max(zone.min, Math.min(zone.max, parsed)); + self.data[zone.field] = zone.float ? Math.round(parsed * 100) / 100 : parsed; + self.updateUI(); + } + } + 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"; + const display = value.length > 30 ? value.substring(0, 27) + "..." : value; + ctx.fillText(display.replace('\n', '\\n'), x + 5, y + h/2 + 4); + }; + + 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; + const colorBoxY = y + 0; + const colorBoxH = h + 0; + ctx.fillStyle = value; + ctx.fillRect(colorBoxX, colorBoxY, 20, colorBoxH); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1.5; + ctx.strokeRect(colorBoxX, colorBoxY, 20, colorBoxH); + }; + + 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; + const circleY = y + h/2; + ctx.fillStyle = value ? "#4CAF50" : "#555"; + ctx.beginPath(); + ctx.arc(circleX, circleY, 8, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.font = "11px sans-serif"; + ctx.textAlign = "left"; + ctx.fillText(value ? "ON" : "OFF", x + 8, y + h/2 + 4); + }; + + this.showComboSelector = function(fieldName) { + const options = { + font_name: self.data.font_list || ["default"], + vertical_align: ["top", "center", "bottom"], + horizontal_align: ["left", "center", "right"], + text_orientation: ["horizontal", "vertical"] + }; + const list = options[fieldName] || []; + 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 = (e) => { + e.stopPropagation(); + e.preventDefault(); + console.log("✅ Combo selected:", opt); + self.data[fieldName] = opt; + self.updateUI(); + menu.remove(); + }; + menu.appendChild(item); + }); + const widgetY = self.widgetYPositions[fieldName] || 200; + const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || + document.querySelector("canvas")?.getBoundingClientRect(); + if (canvasRect && self.pos) { + const menuX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; + const menuY = canvasRect.top + self.pos[1] + widgetY + self.rowHeight; + menu.style.left = menuX + "px"; + menu.style.top = menuY + "px"; + } else { + menu.style.left = "250px"; + menu.style.top = "200px"; + } + document.body.appendChild(menu); + setTimeout(() => { + const closeHandler = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener("mousedown", closeHandler); + } + }; + document.addEventListener("mousedown", closeHandler); + }, 100); + }; + + this.showTextInput = function() { + 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 = '✅'; + 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); + + const widgetY = self.widgetYPositions["text"] || 80; + const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect(); + if (canvasRect && self.pos) { + const inputX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; + const inputY = canvasRect.top + self.pos[1] + widgetY + (self.rowHeight * 2); + popup.style.left = inputX + 'px'; + popup.style.top = inputY + 'px'; + } + + document.body.appendChild(popup); + input.focus(); + + saveBtn.onclick = (e) => { + e.stopPropagation(); + e.preventDefault(); + self.data.text = input.value; + self.updateUI(); + popup.remove(); + }; + }; + + this.openColorPicker = function(fieldName) { + const currentColor = self.data[fieldName] || '#FFFFFF'; + console.log("🎨 Opening color picker:", fieldName, currentColor); + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.value = currentColor; + colorInput.style.display = 'none'; + document.body.appendChild(colorInput); + colorInput.addEventListener('change', (e) => { + e.stopPropagation(); + e.preventDefault(); + const newColor = e.target.value.toUpperCase(); + self.data[fieldName] = newColor; + console.log("🎨 Color changed:", newColor); + self.updateUI(); + }, { once: false }); + const widgetY = self.widgetYPositions[fieldName] || 200; + const canvasRect = document.querySelector("#graph-canvas")?.getBoundingClientRect() || + document.querySelector("canvas")?.getBoundingClientRect(); + if (canvasRect && self.pos) { + const pickerX = canvasRect.left + self.pos[0] + self.padding + self.labelWidth; + const pickerY = canvasRect.top + self.pos[1] + widgetY; + colorInput.style.position = 'fixed'; + colorInput.style.left = pickerX + 'px'; + colorInput.style.top = pickerY + 'px'; + colorInput.style.zIndex = '10002'; + colorInput.style.width = '1px'; + colorInput.style.height = '1px'; + colorInput.style.opacity = '0'; + console.log("📍 Picker positioned at:", pickerX, pickerY); + requestAnimationFrame(() => { + if (colorInput.showPicker) { + colorInput.showPicker(); + } else { + colorInput.click(); + } + }); + } else { + colorInput.style.position = 'fixed'; + colorInput.style.left = (window.innerWidth / 2) + 'px'; + colorInput.style.top = (window.innerHeight / 2) + 'px'; + colorInput.style.zIndex = '10002'; + requestAnimationFrame(() => { + if (colorInput.showPicker) { + colorInput.showPicker(); + } else { + colorInput.click(); + } + }); + } + const clickOutsideHandler = (e) => { + if (e.target !== colorInput) { + colorInput.remove(); + document.removeEventListener("mousedown", clickOutsideHandler); + } + }; + setTimeout(() => { + document.addEventListener("mousedown", clickOutsideHandler); + }, 200); + }; + + this.syncData = function() { + if (this.hiddenWidget) { + this.hiddenWidget.value = JSON.stringify(self.data, null, 2) + .replace(/\\u([0-9a-fA-F]{4})/g, function(match, p1) { + return String.fromCharCode(parseInt(p1, 16)); + }); + } + }; + + this.updateUI = function() { + self.syncData(); + if (self.graph) self.graph.setDirtyCanvas(true, true); + }; + + const onSerialize = this.onSerialize; + this.onSerialize = function(o) { + self.syncData(); + return onSerialize ? onSerialize.apply(this, arguments) : undefined; + }; + + const onExecute = this.onExecute; + this.onExecute = function() { + self.syncData(); + return onExecute ? onExecute.apply(this, arguments) : undefined; + }; + + return result; + }; + } + } +}); +console.log("🦊 [RS_TextOverlay] Extension initialized"); \ No newline at end of file