Added new node - Rayko Intermediate Spline Mask
This commit is contained in:
+10
-10
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
+4
-5
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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)`);
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user