diff --git a/Rayko_Image_Text .py b/Rayko_Image_Text .py index ea6aa4a..076f873 100644 --- a/Rayko_Image_Text .py +++ b/Rayko_Image_Text .py @@ -22,8 +22,8 @@ class LoadImageWithText: return { "required": { "mode": (["read", "write"], {"default": "read"}), - "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "Префикс для сохранения в output (только write)"}), - "text_input": ("STRING", {"default": "", "multiline": True, "tooltip": "Текст для записи (только в write)"}), + "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "Prefix to save to output (write only)"}), + "text_input": ("STRING", {"default": "", "multiline": True, "tooltip": "Text to write (write only)"}), "image": (files, {"image_upload": True}), } } @@ -38,14 +38,14 @@ class LoadImageWithText: try: input_path = folder_paths.get_annotated_filepath(image) if not os.path.exists(input_path): - raise FileNotFoundError(f"Файл не найден: {input_path}") + raise FileNotFoundError(f"File not found: {input_path}") if mode == "write": return self.write_mode(input_path, text_input, filename_prefix) else: return self.read_mode(input_path) except Exception as e: - print(f"Ошибка в process: {e}") + print(f"Error in process: {e}") import traceback traceback.print_exc() dummy_img = torch.zeros((1, 64, 64, 3), dtype=torch.float32) @@ -55,7 +55,7 @@ class LoadImageWithText: pil_img = Image.open(input_path) text = pil_img.info.get("Description", "") image_tensor = self.pil_to_tensor(pil_img) - print(f"[READ] Текст: '{text}'") + print(f"[READ] Text: '{text}'") return (image_tensor, text) def write_mode(self, input_path, text, filename_prefix): @@ -65,7 +65,7 @@ class LoadImageWithText: pnginfo = PngImagePlugin.PngInfo() if text: pnginfo.add_text("Description", text) - print(f"[WRITE] Записывается текст: '{text}'") + print(f"[WRITE] Text is recorded: '{text}'") output_dir = folder_paths.get_output_directory() @@ -78,20 +78,20 @@ class LoadImageWithText: break counter += 1 - print(f"[WRITE] Сохраняется в: {output_path}") + print(f"[WRITE] Saved in: {output_path}") if pil_img.mode not in ('RGB', 'RGBA'): pil_img = pil_img.convert('RGB') pil_img.save(output_path, format="PNG", pnginfo=pnginfo) - print(f"[WRITE] Файл сохранён: {os.path.exists(output_path)}") + print(f"[WRITE] File saved: {os.path.exists(output_path)}") try: check_img = Image.open(output_path) saved_text = check_img.info.get("Description", "") - print(f"[CHECK] Текст в сохранённом файле: '{saved_text}'") + print(f"[CHECK] Text in the saved file: '{saved_text}'") except Exception as e: - print(f"[CHECK] Ошибка проверки: {e}") + print(f"[CHECK] Verification error: {e}") return (image_tensor, text) diff --git a/Rayko_Intermediate_Spline_Mask.py b/Rayko_Intermediate_Spline_Mask.py new file mode 100644 index 0000000..b1710a8 --- /dev/null +++ b/Rayko_Intermediate_Spline_Mask.py @@ -0,0 +1,207 @@ +import os +import time +import json +import torch +import numpy as np +from PIL import Image, ImageDraw +import folder_paths + +try: + from server import PromptServer + from aiohttp import web + SERVER_AVAILABLE = True +except ImportError: + SERVER_AVAILABLE = False + print("\033[91m[InSPLINE 🦊] Warning: Server modules not available.\033[0m") + +PENDING_DECISIONS = {} + +print("\033[93m🦊\033[0m \033[93mRaykoStudio - RS Intermediate Spline Mask \033[92mLOADED\033[0m") + +class RaykoIntermediateSplineMask: + @classmethod + def INPUT_TYPES(cls): + inputs = { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "coordinates": ("STRING", {"multiline": False, "default": "[]"}), + } + } + if SERVER_AVAILABLE: + inputs["hidden"] = { + "unique_id": "UNIQUE_ID", + "prompt_id": "PROMPT_ID", + } + return inputs + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("image", "mask") + FUNCTION = "create_mask" + CATEGORY = "🦊 RaykoStudio" + OUTPUT_NODE = True + + def create_mask(self, image, coordinates="[]", unique_id=None, prompt_id=None): + unique_id = str(unique_id) if unique_id else "unknown" + + if SERVER_AVAILABLE and unique_id: + if unique_id not in PENDING_DECISIONS: + PENDING_DECISIONS[unique_id] = { + "status": "pending", + "coordinates": coordinates + } + 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()) + + try: + img_tensor = image[0] + i = 255. * img_tensor.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + filename = f"inspline_{unique_id}_{prompt_id}.png" + subfolder = "inspline" + full_output_folder = os.path.join(folder_paths.get_temp_directory(), subfolder) + os.makedirs(full_output_folder, exist_ok=True) + img.save(os.path.join(full_output_folder, filename)) + + PromptServer.instance.send_sync("rayko.inspline.show", { + "node_id": unique_id, + "image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}" + }) + except Exception as e: + print(f"[InSPLINE 🦊] Error saving image: {e}") + + while True: + state = PENDING_DECISIONS.get(unique_id, {}) + current_status = state.get("status", "pending") + 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 + + if current_status == "removed": + print(f"[InSPLINE 🦊] Node {unique_id} REMOVED! Cleaning up...") + if unique_id in PENDING_DECISIONS: + del PENDING_DECISIONS[unique_id] + break + + 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))) + + img_tensor = image[0] + h, w = img_tensor.shape[0], img_tensor.shape[1] + + mask_np = np.zeros((h, w), dtype=np.float32) + + try: + coords = json.loads(coordinates.replace("'", '"')) + + if isinstance(coords, list) and len(coords) >= 3: + pts = [(max(0, min(int(float(p['x'])), w-1)), + max(0, min(int(float(p['y'])), h-1))) for p in coords] + + pil_mask = Image.new('L', (w, h), 0) + draw = ImageDraw.Draw(pil_mask) + 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") + else: + print(f"[InSPLINE 🦊] ⚠️ Less than 3 points - empty mask") + except Exception as e: + print(f"[InSPLINE 🦊] ❌ Error parsing coordinates: {e}") + + mask_tensor = torch.from_numpy(mask_np).unsqueeze(0) + return (image, mask_tensor) + + +if SERVER_AVAILABLE: + @PromptServer.instance.routes.post("/rayko/inspline/decision") + async def inspline_decision(request): + try: + data = await request.json() + node_id = str(data.get("node_id")) + 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: + print(f"[InSPLINE 🦊] API Error: {e}") + import traceback + traceback.print_exc() + return web.Response(status=500, text=str(e)) + + @PromptServer.instance.routes.post("/rayko/inspline/cleanup") + async def inspline_cleanup(request): + try: + 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: + print(f"[InSPLINE 🦊] Cleanup Error: {e}") + return web.Response(status=500, text=str(e)) + +NODE_CLASS_MAPPINGS = {"RaykoIntermediateSplineMask": RaykoIntermediateSplineMask} +NODE_DISPLAY_NAME_MAPPINGS = {"RaykoIntermediateSplineMask": "🦊 RS Intermediate Spline Mask"} \ No newline at end of file diff --git a/Rayko_Save_Image.py b/Rayko_Save_Image.py index dd9825e..cd3652f 100644 --- a/Rayko_Save_Image.py +++ b/Rayko_Save_Image.py @@ -116,13 +116,12 @@ class RSSaveImage: theme="light", prompt=None, extra_pnginfo=None): filename_prefix += self.prefix_append - # Определяем цвета в зависимости от темы if theme == "dark": - text_color_rgb = (255, 255, 255) # белый текст - bg_color_rgb = (0, 0, 0) # чёрный фон + text_color_rgb = (255, 255, 255) + bg_color_rgb = (0, 0, 0) else: # light - text_color_rgb = (0, 0, 0) # чёрный текст - bg_color_rgb = (255, 255, 255) # белый фон + text_color_rgb = (0, 0, 0) + bg_color_rgb = (255, 255, 255) batch_size = images.shape[0] diff --git a/Rayko_Save_Image_Pair.py b/Rayko_Save_Image_Pair.py index b403087..7bd77e5 100644 --- a/Rayko_Save_Image_Pair.py +++ b/Rayko_Save_Image_Pair.py @@ -120,15 +120,13 @@ class SaveImagePair: theme="light", scale_factor=1.0, prompt=None, extra_pnginfo=None): filename_prefix += self.prefix_append - # Определяем цвета в зависимости от темы if theme == "dark": - text_color_rgb = (255, 255, 255) # белый текст - bg_color_rgb = (0, 0, 0) # чёрный фон + text_color_rgb = (255, 255, 255) + bg_color_rgb = (0, 0, 0) else: # light - text_color_rgb = (0, 0, 0) # чёрный текст - bg_color_rgb = (255, 255, 255) # белый фон + text_color_rgb = (0, 0, 0) + bg_color_rgb = (255, 255, 255) - # Проверка батчей batch_ref = reference.shape[0] batch_fin = final.shape[0] if batch_ref != batch_fin: @@ -178,7 +176,6 @@ class SaveImagePair: else: combined_img = combine_images_pil([ref_block, fin_block], 'vertical') - # Масштабирование по самой длинной стороне if scale_factor < 1.0: old_width, old_height = combined_img.size max_side = max(old_width, old_height) diff --git a/Rayko_Text_Overlay.py b/Rayko_Text_Overlay.py deleted file mode 100644 index 00bb8e9..0000000 --- a/Rayko_Text_Overlay.py +++ /dev/null @@ -1,663 +0,0 @@ -""" -🦊 RaykoStudio - RS Text Overlay v3.10 -====================================== -Наложение текста на изображение с привязкой к маске. - -v3.10 fix: -• 🛠 Исправлена ошибка OpenCV в calculate_mask_rotation -• 🛠 Правильная конвертация маски (torch → numpy → CV_8UC1) -• 🎨 COLOR тип + JavaScript UI (550px ширина) -• 🎚️ shadow_blur: FLOAT (0.0-1.0, step 0.05) -• ❌ Убран blend_mode (всегда normal) - -Структура файлов: - custom_nodes/ComfyUI_RaykoStudio/ - ├── Rayko_Text_Overlay.py ← этот файл - ├── web/ - │ └── extensions/ - │ └── rs_text_overlay.js ← JavaScript UI (550px) - └── fonts/ ← папка со шрифтами - -Автор: RaykoStudio -Лицензия: MIT -""" - -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 re - -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[92mv3.10 LOADED\033[0m") - - -class FontNotFoundError(Exception): pass -class ColorParseError(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: - _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] = {} - - @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"] - 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) - - return { - "required": { - "image": ("IMAGE",), - "mask": ("MASK",), - "text": ("STRING", { - "default": "Текст\nс переносом", - "multiline": True, - "tooltip": "Текст для отображения" - }), - "font_name": (font_list, { - "default": font_list[0] if len(font_list) > 1 else "default", - "tooltip": "Шрифт из папки fonts/" - }), - "text_color": ("COLOR", { - "default": [1.0, 1.0, 1.0, 1.0], - "tooltip": "Цвет текста" - }), - "outline_thickness": ("INT", {"default": 0, "min": 0, "max": 50, "tooltip": "Толщина обводки"}), - "outline_color": ("COLOR", { - "default": [0.3, 0.3, 0.3, 1.0], - "tooltip": "Цвет обводки" - }), - "rotate_with_mask": ("BOOLEAN", {"default": True, "tooltip": "Поворачивать с маской"}), - "text_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), - "min_font_size": ("INT", {"default": 8, "min": 1, "max": 500}), - "max_font_size": ("INT", {"default": 500, "min": 10, "max": 2000}), - "padding": ("INT", {"default": 5, "min": 0, "max": 200}), - "vertical_align": (["top", "center", "bottom"], {"default": "center"}), - "horizontal_align": (["left", "center", "right"], {"default": "center"}), - "line_spacing": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.1}), - "letter_spacing": ("FLOAT", {"default": 0.0, "min": -20.0, "max": 100.0, "step": 0.5}), - "text_orientation": (["horizontal", "vertical"], {"default": "horizontal"}), - }, - "optional": { - "enable_shadow": ("BOOLEAN", {"default": False}), - "shadow_color": ("COLOR", { - "default": [0.2, 0.2, 0.2, 1.0], - "tooltip": "Цвет тени" - }), - "shadow_offset_x": ("INT", {"default": 2, "min": -50, "max": 50}), - "shadow_offset_y": ("INT", {"default": 2, "min": -50, "max": 50}), - "shadow_blur": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}), - "shadow_opacity": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01}), - } - } - - 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": - return None - - strategies = [] - current_dir = os.path.dirname(os.path.abspath(__file__)) - strategies.append(os.path.join(current_dir, "fonts", font_name)) - - if HAS_FOLDER_PATHS: - try: - font_dirs = folder_paths.get_folder_paths("fonts") - if font_dirs: - for font_dir in font_dirs: - strategies.append(os.path.join(font_dir, font_name)) - except KeyError: - self.log("Folder 'fonts' not registered in ComfyUI", level=2) - except Exception as e: - self.log(f"Error accessing folder_paths: {e}", level=1) - - if os.path.isabs(font_name): - strategies.append(font_name) - - for path in strategies: - try: - if path and os.path.isfile(path) and path.lower().endswith(('.ttf', '.otf', '.ttc')): - self.log(f"Font found: {path}", level=2) - return path - except (OSError, PermissionError) as e: - self.log(f"Cannot access {path}: {e}", 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"Не удалось загрузить шрифт: {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, (list, tuple)) and len(color) >= 3: - r = int(min(1.0, max(0.0, float(color[0]))) * 255) - g = int(min(1.0, max(0.0, float(color[1]))) * 255) - b = int(min(1.0, max(0.0, float(color[2]))) * 255) - a = int(min(1.0, max(0.0, float(color[3]))) * 255) if len(color) >= 4 else int(alpha * 255) - result = (r, g, b, a) - - elif isinstance(color, str): - color_str = color.strip() - - if color_str.startswith('#'): - hex_val = color_str[1:] - else: - hex_val = color_str - - if len(hex_val) == 3: - r = int(hex_val[0] * 2, 16) - g = int(hex_val[1] * 2, 16) - b = int(hex_val[2] * 2, 16) - a = int(alpha * 255) - elif len(hex_val) == 6: - r = int(hex_val[0:2], 16) - g = int(hex_val[2:4], 16) - b = int(hex_val[4:6], 16) - a = int(alpha * 255) - elif len(hex_val) == 8: - r = int(hex_val[0:2], 16) - g = int(hex_val[2:4], 16) - b = int(hex_val[4:6], 16) - a = int(hex_val[6:8], 16) - 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 Exception as e: - self.log(f"Color parse error: {e}", level=0) - 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) - char_count = len(line) - line_width = draw.textlength(line, font=font) + letter_spacing * max(0, char_count - 1) - line_height = (bbox[3] - bbox[1]) * line_spacing - else: - if line: - char_bbox = draw.textbbox((0, 0), line[0], font=font) - char_height = char_bbox[3] - char_bbox[1] - line_width = draw.textlength(line[0], font=font) - line_height = (char_height + 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 FontNotFoundError: - 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 - - # ==================== Mask & Rotation (FIXED v3.10) ==================== - def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray: - """ - 🛠 v3.10 FIX: Правильная конвертация маски для OpenCV - - Требуется формат: CV_8UC1 (single channel, 8-bit, uint8) - """ - # 1. Конвертируем в numpy - mask_np = mask_tensor.cpu().numpy() - - # 2. Убираем лишние измерения (если есть batch или channel) - if mask_np.ndim == 3: - # [H, W, C] или [C, H, W] → берём первый канал - if mask_np.shape[0] in [1, 3, 4]: - mask_np = mask_np[0] # [C, H, W] - elif mask_np.shape[-1] in [1, 3, 4]: - mask_np = mask_np[:, :, 0] # [H, W, C] - elif mask_np.ndim == 4: - # [B, H, W, C] или [B, C, H, W] → берём первый элемент - 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] - - # 3. Нормализуем значения (если float 0-1 → uint8 0-255) - if mask_np.dtype == np.float32 or mask_np.dtype == np.float64: - mask_np = (mask_np * 255).astype(np.uint8) - elif mask_np.dtype != np.uint8: - mask_np = mask_np.astype(np.uint8) - - # 4. Убеждаемся, что это 2D массив - if mask_np.ndim != 2: - self.log(f"Mask shape issue: {mask_np.shape}, attempting to fix", level=1) - mask_np = mask_np.squeeze() - if mask_np.ndim != 2: - raise ValueError(f"Cannot convert mask to 2D: {mask_np.shape}") - - # 5. Бинаризация (опционально, для лучших контуров) - _, mask_np = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY) - - return mask_np - - def calculate_mask_rotation(self, mask_np: np.ndarray) -> float: - """ - Вычисляет угол поворота маски через minAreaRect. - - 🛠 v3.10 FIX: mask_np уже в правильном формате CV_8UC1 - """ - mask_hash = hash(mask_np.tobytes()) - if mask_hash in self._contour_cache: - return self._contour_cache[mask_hash] - - try: - # 🛠 FIX: mask_np уже в формате CV_8UC1 из _prepare_mask_for_opencv - 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 Exception as e: - self.log(f"Rotation calculation error: {e}", level=0) - return 0.0 - - # ==================== Text Rendering ==================== - def _draw_single_line(self, draw: ImageDraw.Draw, position: Tuple[float, float], - text: str, font: ImageFont.ImageFont, color: Tuple[int, int, int, int], - letter_spacing: float, text_orientation: str) -> None: - 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: str, config: TextRenderConfig, - container_size: Tuple[int, int]) -> Image.Image: - text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0)) - draw = ImageDraw.Draw(text_layer) - - total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions( - f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}", - text, config.letter_spacing, config.line_spacing, - config.outline_thickness, config.text_orientation - ) - - start_y = (container_size[1] - total_h) // 2 - current_y = start_y - - lines = text.split('\n') if config.text_orientation == "horizontal" else [text.replace('\n', '')] - - for line, line_w, line_h in zip(lines, line_widths, line_heights): - if not line.strip(): - current_y += line_h - continue - - start_x = (container_size[0] - line_w) // 2 - - if config.has_outline: - thickness = config.outline_thickness - steps = max(2, thickness // 2) - for dx in range(-thickness, thickness + 1, steps): - for dy in range(-thickness, thickness + 1, steps): - if dx == 0 and dy == 0: continue - self._draw_single_line(draw, (start_x + dx, current_y + dy), - line, config.font, config.outline_color, - config.letter_spacing, config.text_orientation) - - self._draw_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: Image.Image, config: TextRenderConfig) -> Image.Image: - alpha = text_layer.getchannel("A") - shadow = Image.new("RGBA", text_layer.size, config.shadow_color) - shadow.putalpha(alpha) - - if config.shadow_blur > 0: - blur_radius = int(config.shadow_blur * 20) - if blur_radius > 0: - shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius)) - - offset_x, offset_y = config.shadow_offset - if offset_x or offset_y: - shadow = shadow.transform( - shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y), - Image.BICUBIC, fill=(0, 0, 0, 0) - ) - - 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 - - # ==================== Main Processing ==================== - def _process_single(self, image: torch.Tensor, mask: torch.Tensor, text: str, - font_name: str, config: TextRenderConfig, - padding: int, vertical_align: str, horizontal_align: str, - rotate_with_mask: bool, min_font_size: int, max_font_size: int) -> torch.Tensor: - - 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') - - # 🛠 v3.10 FIX: Правильная конвертация маски - mask_np = self._prepare_mask_for_opencv(mask) - mask_pil = Image.fromarray(mask_np, 'L') - - bbox = mask_pil.getbbox() - if not bbox: - self.log("No bounding box in mask", level=1) - 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, - text: str, - font_name: str, - text_color: Any, - outline_thickness: int, - outline_color: Any, - rotate_with_mask: bool, - text_opacity: float, - min_font_size: int, - max_font_size: int, - padding: int, - vertical_align: str, - horizontal_align: str, - line_spacing: float, - letter_spacing: float, - text_orientation: str, - enable_shadow: bool = False, - shadow_color: Any = None, - shadow_offset_x: int = 2, - shadow_offset_y: int = 2, - shadow_blur: float = 0.0, - shadow_opacity: float = 0.8, - **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") - - 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, padding, vertical_align, horizontal_align, - rotate_with_mask, min_font_size, max_font_size - ) - results.append(result) - return (torch.cat(results, dim=0),) - else: - result = self._process_single( - image, mask, text, font_name, config, padding, - vertical_align, horizontal_align, rotate_with_mask, min_font_size, max_font_size - ) - 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 v3.10" -} - -__all__ = ["RS_TextOverlay", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file diff --git a/fonts/AR Zozh.ttf b/fonts/AR Zozh.ttf new file mode 100644 index 0000000..6529f13 Binary files /dev/null and b/fonts/AR Zozh.ttf differ diff --git a/fonts/Alegreya-Italic.ttf b/fonts/Alegreya Italic.ttf similarity index 100% rename from fonts/Alegreya-Italic.ttf rename to fonts/Alegreya Italic.ttf diff --git a/fonts/allegrettoscriptone.ttf b/fonts/Allegretto Script One.ttf similarity index 100% rename from fonts/allegrettoscriptone.ttf rename to fonts/Allegretto Script One.ttf diff --git a/fonts/AlumniSansCollegiateOne-Regular.ttf b/fonts/AlumniSansCollegiateOne-Regular.ttf deleted file mode 100644 index 047451f..0000000 Binary files a/fonts/AlumniSansCollegiateOne-Regular.ttf and /dev/null differ diff --git a/fonts/Amagro.ttf b/fonts/Amagro.ttf new file mode 100644 index 0000000..609bdcb Binary files /dev/null and b/fonts/Amagro.ttf differ diff --git a/fonts/Arsenica-Alt-Bold-trial.ttf b/fonts/Arsenica Bold.ttf similarity index 100% rename from fonts/Arsenica-Alt-Bold-trial.ttf rename to fonts/Arsenica Bold.ttf diff --git a/fonts/Calvino-Semibold-Italic-trial.ttf b/fonts/Calvino Semibold Italic.ttf similarity index 100% rename from fonts/Calvino-Semibold-Italic-trial.ttf rename to fonts/Calvino Semibold Italic.ttf diff --git a/fonts/Calvino-Semibold-trial.ttf b/fonts/Calvino Semibold.ttf similarity index 100% rename from fonts/Calvino-Semibold-trial.ttf rename to fonts/Calvino Semibold.ttf diff --git a/fonts/Caveat-VariableFont_wght.ttf b/fonts/Caveat.ttf similarity index 100% rename from fonts/Caveat-VariableFont_wght.ttf rename to fonts/Caveat.ttf diff --git a/fonts/Dela Gothic One Regular.ttf b/fonts/Dela Gothic One Regular.ttf new file mode 100644 index 0000000..c563a59 Binary files /dev/null and b/fonts/Dela Gothic One Regular.ttf differ diff --git a/fonts/Equilibrist Bold.ttf b/fonts/Equilibrist Bold.ttf new file mode 100644 index 0000000..03c30cc Binary files /dev/null and b/fonts/Equilibrist Bold.ttf differ diff --git a/fonts/Equilibrist Regular.ttf b/fonts/Equilibrist Regular.ttf new file mode 100644 index 0000000..dce850f Binary files /dev/null and b/fonts/Equilibrist Regular.ttf differ diff --git a/fonts/NotoSansArabic-Regular.ttf b/fonts/NotoSansArabic-Regular.ttf deleted file mode 100644 index 79359c4..0000000 Binary files a/fonts/NotoSansArabic-Regular.ttf and /dev/null differ diff --git a/fonts/Oswald-Bold.ttf b/fonts/Oswald Bold.ttf similarity index 100% rename from fonts/Oswald-Bold.ttf rename to fonts/Oswald Bold.ttf diff --git a/fonts/PhonkSans Light Italic.otf b/fonts/PhonkSans Light Italic.otf new file mode 100644 index 0000000..ee2bfa9 Binary files /dev/null and b/fonts/PhonkSans Light Italic.otf differ diff --git a/fonts/PhonkSans Light.otf b/fonts/PhonkSans Light.otf new file mode 100644 index 0000000..29f8761 Binary files /dev/null and b/fonts/PhonkSans Light.otf differ diff --git a/fonts/PixelifySans-Bold.ttf b/fonts/PixelifySans-Bold.ttf deleted file mode 100644 index 4d8aeb6..0000000 Binary files a/fonts/PixelifySans-Bold.ttf and /dev/null differ diff --git a/fonts/Quicksand-Bold.ttf b/fonts/Quicksand-Bold.ttf deleted file mode 100644 index 07d5127..0000000 Binary files a/fonts/Quicksand-Bold.ttf and /dev/null differ diff --git a/fonts/Ringbearer Cyrillic Medium.otf b/fonts/Ringbearer Cyrillic Medium.otf new file mode 100644 index 0000000..b416e64 Binary files /dev/null and b/fonts/Ringbearer Cyrillic Medium.otf differ diff --git a/fonts/Roboto-Regular.ttf b/fonts/Roboto Regular.ttf similarity index 100% rename from fonts/Roboto-Regular.ttf rename to fonts/Roboto Regular.ttf diff --git a/fonts/Skiff-Regular.otf b/fonts/Skiff Regular.otf similarity index 100% rename from fonts/Skiff-Regular.otf rename to fonts/Skiff Regular.otf diff --git a/fonts/TDVolshebstvo.otf b/fonts/TDVolshebstvo.otf deleted file mode 100644 index 8a57ea4..0000000 Binary files a/fonts/TDVolshebstvo.otf and /dev/null differ diff --git a/fonts/TanaUncialSP.otf b/fonts/Tana Uncia.otf similarity index 100% rename from fonts/TanaUncialSP.otf rename to fonts/Tana Uncia.otf diff --git a/fonts/Yiggivoo Unicode.regular.ttf b/fonts/Yiggivoo Unicode.regular.ttf new file mode 100644 index 0000000..a338ecc Binary files /dev/null and b/fonts/Yiggivoo Unicode.regular.ttf differ diff --git a/fonts/YoungSerif-Regular.ttf b/fonts/YoungSerif-Regular.ttf deleted file mode 100644 index f454fbe..0000000 Binary files a/fonts/YoungSerif-Regular.ttf and /dev/null differ diff --git a/web/intermediate_spline_mask.js b/web/intermediate_spline_mask.js new file mode 100644 index 0000000..e28ddbc --- /dev/null +++ b/web/intermediate_spline_mask.js @@ -0,0 +1,462 @@ +console.log("[InSPLINE 🦊] intermediate_spline_mask.js LOADED!"); +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "RaykoIntermediateSplineMask", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "RaykoIntermediateSplineMask") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + try { + onNodeCreated?.apply(this, arguments); + const node = this; + + node.image = new Image(); + node.imageReady = false; + node.currentStatus = "Waiting..."; + node.buttons = []; + + const coordsWidget = node.widgets?.find(w => w.name === "coordinates"); + if (coordsWidget) { + coordsWidget.hidden = true; + coordsWidget.serializeValue = () => { + return node.properties?.spline_coords || "[]"; + }; + } + + node.addButton = (label, color, callback) => { + node.buttons.push({ + label: label, color: color, callback: callback, + x: 0, y: 0, w: 0, h: 30, hover: false + }); + }; + + node.addButton("✔️ ACCEPT", "#28a745", () => node.sendDecision("approve")); + node.addButton("🔴 CLEAR POINTS", "#dc3545", () => node.sendDecision("reject")); + node.addButton("❌ CANCEL", "#666666", () => node.sendDecision("cancel")); + + node.setSize([450, 600]); + + let _points = []; + let _overlayCanvas = null; + let _syncRunning = false; + let _lastRect = null; + + if (node.properties?.spline_coords && node.properties.spline_coords !== "[]") { + try { + const p = JSON.parse(node.properties.spline_coords); + if (Array.isArray(p)) _points = p; + } catch (e) {} + } + + const updateCoords = () => { + const jsonStr = JSON.stringify(_points); + node.properties = node.properties || {}; + node.properties.spline_coords = jsonStr; + if (coordsWidget) coordsWidget.value = jsonStr; + drawOverlay(); + }; + + const calculateImageRect = () => { + if (!app.canvas) return null; + + const ds = app.canvas.ds; + const canvasEl = app.canvas.canvas; + const scale = ds.scale; + + const graphX = node.pos[0]; + const graphY = node.pos[1]; + + const canvasRect = canvasEl.getBoundingClientRect(); + const nodeScreenX = canvasRect.left + ((graphX + ds.offset[0]) * scale); + const nodeScreenY = canvasRect.top + ((graphY + ds.offset[1]) * scale); + + let widgetsTotalHeight = 0; + if (node.widgets) { + for (const w of node.widgets) { + if (!w.hidden && w.type !== "button") { + widgetsTotalHeight += (w.computeSize ? w.computeSize(node.size[0])[1] : 20); + } + } + } + + const titleBarHeight = LiteGraph.NODE_TITLE_HEIGHT || 30; + const padding = 10; + const footerHeight = 50; + + const availableHeight = (node.size[1] * scale) - (titleBarHeight * scale) - (widgetsTotalHeight * scale) - (footerHeight * scale) - (padding * 2 * scale); + const availableWidth = (node.size[0] * scale) - (padding * 2 * scale); + + if (availableWidth <= 0 || availableHeight <= 0) return null; + + let drawW = availableWidth; + let drawH = availableHeight; + let contentScale = 1; + + if (node.imageReady && node.image) { + const imgRatio = node.image.width / node.image.height; + + if (availableWidth / availableHeight > imgRatio) { + drawH = availableHeight; + drawW = drawH * imgRatio; + } else { + drawW = availableWidth; + drawH = drawW / imgRatio; + } + + contentScale = drawW / node.image.width; + } + + const drawX = nodeScreenX + (padding * scale) + ((availableWidth - drawW) / 2); + const drawY = nodeScreenY + (titleBarHeight * scale) + (widgetsTotalHeight * scale) + (padding * scale) + ((availableHeight - drawH) / 2); + + return { + left: drawX, + top: drawY, + width: drawW, + height: drawH, + scale: contentScale + }; + }; + + const startSyncLoop = () => { + if (_syncRunning) return; + _syncRunning = true; + const syncLoop = () => { + if (!_syncRunning) return; + syncPosition(); + if (_overlayCanvas) requestAnimationFrame(syncLoop); + else _syncRunning = false; + }; + requestAnimationFrame(syncLoop); + }; + + const syncPosition = () => { + if (!_overlayCanvas) return; + const imgRect = calculateImageRect(); + if (!imgRect) return; + + const hasChanged = !_lastRect || + Math.abs(_lastRect.left - imgRect.left) > 0.5 || + Math.abs(_lastRect.top - imgRect.top) > 0.5 || + Math.abs(_lastRect.width - imgRect.width) > 0.5 || + Math.abs(_lastRect.height - imgRect.height) > 0.5; + + if (hasChanged) { + _lastRect = { ...imgRect }; + _overlayCanvas.style.left = `${imgRect.left}px`; + _overlayCanvas.style.top = `${imgRect.top}px`; + _overlayCanvas.style.width = `${imgRect.width}px`; + _overlayCanvas.style.height = `${imgRect.height}px`; + _overlayCanvas.dataset.scale = imgRect.scale; + drawOverlay(); + } + }; + + const createOverlayCanvas = () => { + if (_overlayCanvas) return; + _overlayCanvas = document.createElement("canvas"); + _overlayCanvas.style.cssText = ` + position: fixed !important; z-index: 1000 !important; + pointer-events: auto !important; cursor: crosshair !important; + background: transparent !important; touch-action: none; + border: 1px dashed #00FF00 !important; box-sizing: border-box !important; + display: none; + `; + document.body.appendChild(_overlayCanvas); + + _overlayCanvas.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + const rect = _overlayCanvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const scale = parseFloat(_overlayCanvas.dataset.scale || "1"); + const imgX = x / scale; + const imgY = y / scale; + + if (e.button === 2 || e.ctrlKey) { + let removed = false; + for (let i = _points.length - 1; i >= 0; i--) { + const dist = Math.hypot(_points[i].x - imgX, _points[i].y - imgY); + if (dist < 15 / scale) { + _points.splice(i, 1); + removed = true; + break; + } + } + if (removed) updateCoords(); + } else if (e.button === 0) { + if (_points.length >= 3) { + const distToFirst = Math.hypot(_points[0].x - imgX, _points[0].y - imgY); + if (distToFirst < 20 / scale) { + updateCoords(); + return; + } + } + _points.push({ x: imgX, y: imgY }); + updateCoords(); + } + }); + startSyncLoop(); + }; + + const drawOverlay = () => { + if (!_overlayCanvas || !_lastRect) return; + const width = parseFloat(_overlayCanvas.style.width || "0"); + const height = parseFloat(_overlayCanvas.style.height || "0"); + if (width <= 0 || height <= 0) return; + + const dpr = window.devicePixelRatio || 1; + _overlayCanvas.width = width * dpr; + _overlayCanvas.height = height * dpr; + const ctx = _overlayCanvas.getContext("2d"); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + + const scale = parseFloat(_overlayCanvas.dataset.scale || "1"); + if (_points.length >= 1) { + ctx.beginPath(); + ctx.moveTo(_points[0].x * scale, _points[0].y * scale); + for (let i = 1; i < _points.length; i++) { + ctx.lineTo(_points[i].x * scale, _points[i].y * scale); + } + if (_points.length >= 3) { + ctx.closePath(); + ctx.fillStyle = "rgba(0, 255, 0, 0.3)"; + ctx.fill(); + } + ctx.strokeStyle = "#0f0"; + ctx.lineWidth = 2; + ctx.stroke(); + for (const p of _points) { + ctx.beginPath(); + ctx.arc(p.x * scale, p.y * scale, 4, 0, Math.PI * 2); + ctx.fillStyle = "#f00"; + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + }; + + node.onDrawForeground = function(ctx) { + if (!this.flags.collapsed) { + const [w, h] = this.size; + + let widgetsTotalHeight = 0; + if (this.widgets) { + for (const widget of this.widgets) { + if (!widget.hidden && widget.type !== "button") { + widgetsTotalHeight += (widget.computeSize ? widget.computeSize(w)[1] : 20); + } + } + } + + const titleBarHeight = LiteGraph.NODE_TITLE_HEIGHT || 30; + const padding = 10; + const footerHeight = 50; + + const startY = titleBarHeight + widgetsTotalHeight + padding; + const availableHeight = h - startY - footerHeight - padding; + const availableWidth = w - (padding * 2); + + if (this.imageReady && this.image) { + const imgRatio = this.image.width / this.image.height; + let drawW, drawH; + + if (availableWidth / availableHeight > imgRatio) { + drawH = availableHeight; + drawW = drawH * imgRatio; + } else { + drawW = availableWidth; + drawH = drawW / imgRatio; + } + + const drawX = padding + (availableWidth - drawW) / 2; + const drawY = startY + (availableHeight - drawH) / 2; + + ctx.drawImage(this.image, drawX, drawY, drawW, drawH); + + ctx.strokeStyle = "#444"; + ctx.lineWidth = 1; + ctx.strokeRect(drawX, drawY, drawW, drawH); + } else { + ctx.fillStyle = "#222"; + ctx.fillRect(padding, startY, availableWidth, availableHeight); + ctx.fillStyle = "#555"; + ctx.font = "14px Arial"; + ctx.textAlign = "center"; + ctx.fillText("Waiting for Image...", w / 2, startY + availableHeight / 2); + } + + const btnH = 28; + const btnY = h - 45; + const btnW = (w - 50) / 3; + + this.buttons[0].x = 15; this.buttons[0].y = btnY; this.buttons[0].w = btnW; this.buttons[0].h = btnH; + this.buttons[1].x = 20 + btnW; this.buttons[1].y = btnY; this.buttons[1].w = btnW; this.buttons[1].h = btnH; + this.buttons[2].x = 25 + (btnW * 2); this.buttons[2].y = btnY; this.buttons[2].w = btnW; this.buttons[2].h = btnH; + + for (let btn of this.buttons) { + ctx.fillStyle = btn.hover ? "#444" : "#2a2a2a"; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(btn.x, btn.y, btn.w, btn.h, 6); + else ctx.rect(btn.x, btn.y, btn.w, btn.h); + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = btn.color; + ctx.stroke(); + ctx.fillStyle = btn.color; + ctx.font = "bold 11px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(btn.label, btn.x + btn.w / 2, btn.y + btn.h / 2); + if (btn.hover) app.canvas.canvas.style.cursor = "pointer"; + } + } + }; + + const onResize = node.onResize; + node.onResize = function(size) { + if (onResize) onResize.apply(this, arguments); + _lastRect = null; + }; + + const onMove = node.onMove; + node.onMove = function() { + if (onMove) onMove.apply(this, arguments); + _lastRect = null; + }; + + node.onMouseMove = function(event, pos, graphPos) { + const [x, y] = pos; + let needsRedraw = false; + for (let btn of this.buttons) { + const isOver = x >= btn.x && x <= btn.x + btn.w && y >= btn.y && y <= btn.y + btn.h; + if (btn.hover !== isOver) { btn.hover = isOver; needsRedraw = true; } + } + if (needsRedraw) this.setDirtyCanvas(true, false); + }; + + node.onMouseDown = function(event, pos, graphPos) { + const [x, y] = pos; + for (let btn of this.buttons) { + if (x >= btn.x && x <= btn.x + btn.w && y >= btn.y && y <= btn.y + btn.h) { + btn.callback(); + return true; + } + } + }; + + node.sendDecision = async function(decision) { + const currentCoords = node.properties?.spline_coords || "[]"; + this.currentStatus = `Sending ${decision}...`; + this.setDirtyCanvas(true, true); + + if (decision === "cancel") { + try { + await api.interrupt(); + } catch (e) { console.error("Interrupt failed:", e); } + } + + if (decision === "reject") { + _points = []; + updateCoords(); + } + + try { + const resp = await fetch("/rayko/inspline/decision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + node_id: this.id.toString(), + decision: decision, + coordinates: currentCoords + }) + }); + + if (resp.ok) { + if (decision === "approve") { + this.currentStatus = "✅ Approved! Processing..."; + } else if (decision === "reject") { + this.currentStatus = "🔄 Points cleared. Draw & Approve!"; + } else { + this.currentStatus = "❌ Cancelled."; + } + } else { + this.currentStatus = "Error: " + resp.statusText; + } + } catch (err) { + this.currentStatus = "Error: Connection Failed"; + } + this.setDirtyCanvas(true, true); + }; + + node.onRemoved = function() { + console.log(`[InSPLINE 🦊] Node ${this.id} removed, sending cleanup signal...`); + + _syncRunning = false; + if (_overlayCanvas) { + _overlayCanvas.remove(); + _overlayCanvas = null; + } + + fetch("/rayko/inspline/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + node_id: this.id.toString() + }) + }).catch(err => { + console.error("[InSPLINE 🦊] Cleanup signal failed:", err); + }); + }; + + createOverlayCanvas(); + + const originalOnRendered = app.canvas.onRendered; + app.canvas.onRendered = function() { + if (originalOnRendered) originalOnRendered.apply(this, arguments); + if (_overlayCanvas && node.imageReady) { + _lastRect = null; + syncPosition(); + } + }; + + } catch (error) { + console.error("[InSPLINE 🦊] Critical Error:", error); + } + }; + }, + + setup() { + api.addEventListener("rayko.inspline.show", (event) => { + const { node_id, image_url } = event.detail; + const node = app.graph.getNodeById(node_id); + if (node) { + node.image.src = image_url + "&t=" + Date.now(); + node.image.onload = () => { + node.imageReady = true; + const overlay = document.querySelector(`canvas[style*="z-index: 1000"]`); + if (overlay) { + overlay.style.display = "block"; + } + setTimeout(() => { + _lastRect = null; + syncPosition(); + }, 100); + node.setDirtyCanvas(true, true); + }; + if (node.size[1] < 600) node.setSize([node.size[0], 600]); + node.currentStatus = "🎨 Draw mask, then APPROVE!"; + app.canvas.centerOnNode(node); + node.setDirtyCanvas(true, true); + } + }); + } +}); \ 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 94d1f81..0000000 --- a/web/rs_text_overlay.js +++ /dev/null @@ -1,152 +0,0 @@ -import { app } from "../../scripts/app.js"; - -// ==================== КОНФИГУРАЦИЯ ==================== -const NODE_TYPE = "RS_TextOverlay"; -const NODE_WIDTH = 350; -const NODE_HEIGHT = 750; -const COLOR_INPUTS = ['text_color', 'outline_color', 'shadow_color']; -// ===================================================== - -app.registerExtension({ - name: "RaykoStudio.TextOverlay", - - async init() { - const style = document.createElement('style'); - style.textContent = ` - /* Исправление чёрного текста на чёрном фоне */ - .litegraph .litemenu-entry.color_widget input[type="text"] { - color: #ffffff !important; - text-shadow: 0 0 2px #000000, 0 0 4px #000000; - } - - /* Цветная полоса виджета */ - .litegraph .litemenu-entry.color_widget .color_preview { - border: 1px solid #ffffff; - box-shadow: 0 0 3px rgba(0,0,0,0.5); - } - - /* Улучшенная читаемость для тёмных цветов */ - .litegraph .litemenu-entry input[type="text"][value="#000000"], - .litegraph .litemenu-entry input[type="text"][value="#000"] { - color: #ffffff !important; - background: #333333 !important; - } - - /* Общий стиль для всех COLOR виджетов ноды */ - .node.RS_TextOverlay .widget_color input { - color: #ffffff !important; - text-shadow: 0 0 2px #000000; - font-weight: 500; - } - - /* Стили для светлых цветов */ - .litegraph .litemenu-entry input[type="text"][value="#FFFFFF"], - .litegraph .litemenu-entry input[type="text"][value="#FFF"] { - color: #000000 !important; - text-shadow: none; - } - `; - document.head.appendChild(style); - console.log("🦊 Color widget styles applied"); - }, - - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name !== NODE_TYPE) return; - - const origOnNodeCreated = nodeType.prototype.onNodeCreated; - - nodeType.prototype.onNodeCreated = function() { - const result = origOnNodeCreated?.apply(this, arguments); - - // 📐 Устанавливаем ширину ноды 400px - this.size = [NODE_WIDTH, NODE_HEIGHT]; - this.setSize([NODE_WIDTH, this.size[1]]); - - // 🎨 Настраиваем COLOR виджеты для лучшей читаемости - if (this.widgets) { - this.widgets.forEach((widget) => { - if (COLOR_INPUTS.includes(widget.name)) { - // Сохраняем оригинальный callback - const origCallback = widget.callback; - - // Переопределяем callback для обновления стилей - widget.callback = function(value) { - const result = origCallback?.apply(this, arguments); - - // Обновляем стиль в зависимости от цвета - if (widget.element) { - const brightness = getColorBrightness(value); - widget.element.style.color = brightness < 0.5 ? '#ffffff' : '#000000'; - widget.element.style.textShadow = brightness < 0.5 - ? '0 0 2px #000000, 0 0 4px #000000' - : 'none'; - } - - return result; - }; - - // Применяем стили сразу при создании - setTimeout(() => { - if (widget.element && widget.value) { - const brightness = getColorBrightness(widget.value); - widget.element.style.color = brightness < 0.5 ? '#ffffff' : '#000000'; - widget.element.style.textShadow = brightness < 0.5 - ? '0 0 2px #000000, 0 0 4px #000000' - : 'none'; - } - }, 100); - } - }); - } - - this.setProperty("rs_text_overlay_version", "3.9"); - this.setProperty("node_width", NODE_WIDTH); - - return result; - }; - }, - - async loadedGraphNode(node, app) { - if (node.type !== NODE_TYPE) return; - - // 📐 Убеждаемся, что ширина установлена правильно - if (node.size[0] < NODE_WIDTH) { - node.setSize([NODE_WIDTH, node.size[1]]); - } - } -}); - -// 🎨 Утилита для определения яркости цвета -function getColorBrightness(color) { - if (!color) return 1.0; - - let r, g, b; - - if (Array.isArray(color)) { - // [R, G, B, A] формат (0-1) - r = color[0] || 0; - g = color[1] || 0; - b = color[2] || 0; - } else if (typeof color === 'string') { - // HEX формат - const hex = color.replace('#', ''); - if (hex.length === 3) { - r = parseInt(hex[0] + hex[0], 16) / 255; - g = parseInt(hex[1] + hex[1], 16) / 255; - b = parseInt(hex[2] + hex[2], 16) / 255; - } else if (hex.length >= 6) { - r = parseInt(hex.substring(0, 2), 16) / 255; - g = parseInt(hex.substring(2, 4), 16) / 255; - b = parseInt(hex.substring(4, 6), 16) / 255; - } else { - return 1.0; - } - } else { - return 1.0; - } - - // Формула яркости (ITU-R BT.709) - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -} - -console.log(`🦊 RS Text Overlay UI Extension registered for ${NODE_TYPE} (${NODE_WIDTH}px)`); \ No newline at end of file diff --git a/web/spline_mask.js b/web/spline_mask.js index 2ff20dd..499f25a 100644 --- a/web/spline_mask.js +++ b/web/spline_mask.js @@ -100,7 +100,6 @@ app.registerExtension({ } }; - // 🔥 РАСЧЁТ ВЫСОТЫ ЗАГОЛОВКА const getHeaderHeight = () => { let lastWidgetBottom = 0; @@ -123,7 +122,6 @@ app.registerExtension({ return lastWidgetBottom - 32; }; - // 🔥 РАСЧЁТ КООРДИНАТ ОВЕРЛЕЯ const calculateImageRect = () => { if (!_imagePreview || !app.canvas) return null; @@ -136,7 +134,6 @@ app.registerExtension({ const canvasRect = canvasEl.getBoundingClientRect(); - // 🔥 ПРАВИЛЬНАЯ ФОРМУЛА: (graph + offset) * scale const nodeScreenX = canvasRect.left + ((graphX + ds.offset[0]) * scale); const nodeScreenY = canvasRect.top + ((graphY + ds.offset[1]) * scale); @@ -162,7 +159,6 @@ app.registerExtension({ const drawX = nodeScreenX + nodePadding + (previewWidth - drawW) / 2; const drawY = nodeScreenY + scaledHeaderHeight + nodePadding + (previewHeight - drawH) / 2; - // 🔥 КОРРЕКЦИЯ РАЗМЕРОВ ОВЕРЛЕЯ const overlayExpandX = 2 * scale; const overlayExpandTop = 2 * scale; const overlayShrinkBottom = 4 * scale; @@ -181,7 +177,6 @@ app.registerExtension({ }; }; - // 🔥 НЕПРЕРЫВНЫЙ ЦИКЛ СИНХРОНИЗАЦИИ (60 FPS) const startSyncLoop = () => { if (_syncRunning) return; _syncRunning = true; @@ -232,7 +227,6 @@ app.registerExtension({ if (_overlayCanvas) return; _overlayCanvas = document.createElement("canvas"); - // 🔥 ЧИСТЫЙ СТИЛЬ: прозрачный фон, зелёная рамка _overlayCanvas.style.cssText = ` position: fixed !important; z-index: 1000 !important; @@ -286,7 +280,6 @@ app.registerExtension({ startSyncLoop(); }; - // 🔥 ОТРИСОВКА ПРЕВЬЮ node.onDrawForeground = function(ctx) { const headerHeightGraph = getHeaderHeight();