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