Minor code improvements

This commit is contained in:
Raykosan
2026-03-23 13:36:44 +03:00
parent f08409fa94
commit d5d5746f1e
2 changed files with 636 additions and 181 deletions
+561 -155
View File
@@ -8,6 +8,12 @@ import functools
from typing import Dict, Tuple, List, Optional, Any
from dataclasses import dataclass
import json
import time
import re
import unicodedata
from contextlib import contextmanager
from collections import OrderedDict
try:
from comfy.utils import ProgressBar as ComfyProgressBar
HAS_PBAR = True
@@ -17,6 +23,7 @@ except ImportError:
def ComfyProgressBar(total: int, desc: str = ""):
class DummyPBar:
def update(self, n: int = 1): pass
def update_absolute(self, n: int): pass
return DummyPBar()
try:
@@ -43,6 +50,9 @@ class TextRenderConfig:
shadow_offset: Tuple[int, int]
shadow_blur: float
text_opacity: int = 100
use_supersampling: bool = False
use_edge_smoothing: bool = True
supersampling_factor: int = 2
@property
def has_outline(self) -> bool:
@@ -52,39 +62,111 @@ class TextRenderConfig:
def has_shadow(self) -> bool:
return self.enable_shadow and any(self.shadow_offset)
class LRUCache:
def __init__(self, max_size: int = 100):
self.cache = OrderedDict()
self.max_size = max_size
def get(self, key):
if key not in self.cache:
return None
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.max_size:
self.cache.popitem(last=False)
def clear(self):
self.cache.clear()
def __len__(self):
return len(self.cache)
class HighQualityTextRenderer:
@staticmethod
def render_with_supersampling(text: str, config: TextRenderConfig,
container_size: Tuple[int, int],
render_func, scale_factor: int = 2) -> Image.Image:
scaled_size = (container_size[0] * scale_factor, container_size[1] * scale_factor)
temp_config = TextRenderConfig(
font=None,
text_color=config.text_color,
outline_color=config.outline_color,
outline_thickness=config.outline_thickness * scale_factor,
letter_spacing=config.letter_spacing * scale_factor,
line_spacing=config.line_spacing,
text_orientation=config.text_orientation,
enable_shadow=config.enable_shadow,
shadow_color=config.shadow_color,
shadow_offset=(config.shadow_offset[0] * scale_factor,
config.shadow_offset[1] * scale_factor),
shadow_blur=config.shadow_blur,
text_opacity=config.text_opacity,
use_supersampling=False,
use_edge_smoothing=False
)
try:
scaled_font_size = int(config.font.size * scale_factor)
temp_config.font = ImageFont.truetype(
config.font.path if hasattr(config.font, 'path') else "default",
scaled_font_size
)
except:
temp_config.font = config.font
temp_layer = render_func(text, temp_config, scaled_size)
result = temp_layer.resize(container_size, Image.Resampling.LANCZOS)
return result
@staticmethod
def apply_edge_smoothing(text_layer: Image.Image) -> Image.Image:
arr = np.array(text_layer)
if arr.ndim == 3 and arr.shape[2] == 4:
alpha = arr[:, :, 3]
rgb = arr[:, :, :3]
edges = cv2.Canny(alpha, 50, 150)
if np.any(edges):
smoothed_rgb = cv2.bilateralFilter(rgb, 5, 50, 50)
alpha_smoothed = cv2.GaussianBlur(alpha, (3, 3), 0.5)
edge_mask = edges > 0
edge_mask_3d = np.stack([edge_mask] * 4, axis=2)
result_arr = np.dstack([smoothed_rgb, alpha_smoothed])
original_arr = np.dstack([rgb, alpha])
result = np.where(edge_mask_3d, result_arr, original_arr)
return Image.fromarray(result.astype(np.uint8), 'RGBA')
return text_layer
class RS_TextOverlay:
WEB_DIRECTORY = "web"
_font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
_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
_render_cache: LRUCache = None
_hex_color_pattern = re.compile(r'^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$')
_hex_short_pattern = re.compile(r'^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$')
@classmethod
def INPUT_TYPES(cls):
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(current_dir, "fonts")
font_list = []
default_font_path = os.path.join(fonts_dir, "Arial.ttf")
if os.path.isfile(default_font_path):
font_list.append("default")
font_list = ["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
font_list.append(f)
return {
"required": {
"image": ("IMAGE",),
@@ -107,7 +189,10 @@ class RS_TextOverlay:
"shadow_offset_y": 2,
"shadow_blur": 0.0,
"shadow_opacity": 0.8,
"font_list": font_list
"font_list": font_list,
"use_supersampling": False,
"use_edge_smoothing": True,
"supersampling_factor": 2
}),
"hidden": True
}),
@@ -121,11 +206,168 @@ class RS_TextOverlay:
def __init__(self):
self.debug = False
self.debug_log = []
self.quality_renderer = HighQualityTextRenderer()
if self._render_cache is None:
self.__class__._render_cache = LRUCache(max_size=100)
self.performance_metrics = {
'render_time': [],
'font_find_time': [],
'mask_process_time': [],
'total_time': []
}
self._mask_cache = LRUCache(max_size=50)
def log(self, message: str, level: int = 1):
if self.debug or level <= 0:
timestamp = time.strftime("%H:%M:%S")
prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•")
print(f"{prefix} RS_TextOverlay: {message}")
print(f"[{timestamp}] {prefix} RS_TextOverlay: {message}")
if self.debug:
self.debug_log.append(f"[{timestamp}] {prefix} {message}")
if len(self.debug_log) > 1000:
self.debug_log = self.debug_log[-500:]
@contextmanager
def measure_time(self, metric_name: str):
start = time.perf_counter()
yield
elapsed = time.perf_counter() - start
if metric_name in self.performance_metrics:
self.performance_metrics[metric_name].append(elapsed)
if len(self.performance_metrics[metric_name]) > 100:
self.performance_metrics[metric_name] = self.performance_metrics[metric_name][-50:]
def _validate_inputs(self, image: torch.Tensor, mask: torch.Tensor) -> bool:
if image is None or mask is None:
self.log("Image or mask is None", level=0)
return False
if image.numel() == 0 or mask.numel() == 0:
self.log("Empty tensor", level=0)
return False
if image.ndim not in [3, 4]:
self.log(f"Invalid image dimensions: {image.ndim}", level=0)
return False
if mask.ndim not in [2, 3, 4]:
self.log(f"Invalid mask dimensions: {mask.ndim}", level=0)
return False
if torch.isnan(image).any() or torch.isinf(image).any():
self.log("Image contains NaN or Inf values", level=0)
return False
return True
def _validate_text(self, text: str) -> str:
if not isinstance(text, str):
return "Текст"
if not text.strip():
return "Текст"
text = unicodedata.normalize('NFKC', text)
text = ''.join(char for char in text if char.isprintable() or char == '\n')
max_length = 1000
if len(text) > max_length:
text = text[:max_length] + "..."
self.log(f"Text truncated to {max_length} characters", level=1)
return text
def _parse_and_validate_node_data(self, node_data: str) -> dict:
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(current_dir, "fonts")
font_list = ["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)
defaults = {
'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,
'use_supersampling': False,
'use_edge_smoothing': True,
'supersampling_factor': 2,
'min_font_size': 8,
'max_font_size': 500,
'padding': 5
}
if not node_data:
return defaults
try:
data = json.loads(node_data)
data['text'] = self._validate_text(data.get('text', defaults['text']))
data['outline_thickness'] = max(0, min(50, data.get('outline_thickness', 0)))
data['text_opacity'] = max(0.0, min(1.0, data.get('text_opacity', 1.0)))
data['line_spacing'] = max(0.5, min(3.0, data.get('line_spacing', 1.0)))
data['letter_spacing'] = max(-20.0, min(100.0, data.get('letter_spacing', 0.0)))
data['shadow_blur'] = max(0.0, min(1.0, data.get('shadow_blur', 0.0)))
data['shadow_opacity'] = max(0.0, min(1.0, data.get('shadow_opacity', 0.8)))
data['shadow_offset_x'] = max(-50, min(50, data.get('shadow_offset_x', 2)))
data['shadow_offset_y'] = max(-50, min(50, data.get('shadow_offset_y', 2)))
data['supersampling_factor'] = max(2, min(4, data.get('supersampling_factor', 2)))
data['min_font_size'] = max(1, min(100, data.get('min_font_size', 8)))
data['max_font_size'] = max(data['min_font_size'], min(1000, data.get('max_font_size', 500)))
data['font_list'] = font_list
return {**defaults, **data}
except json.JSONDecodeError as e:
self.log(f"Error parsing node_data: {e}", level=0)
return defaults
except Exception as e:
self.log(f"Error validating node_data: {e}", level=0)
return defaults
def _is_simple_rectangle(self, mask: np.ndarray) -> bool:
try:
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return False
contour = max(contours, key=cv2.contourArea)
epsilon = 0.02 * cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, epsilon, True)
if len(approx) == 4:
area_contour = cv2.contourArea(contour)
area_approx = cv2.contourArea(approx)
return area_approx > 0.9 * area_contour
return False
except Exception as e:
self.log(f"Error checking rectangle: {e}", level=2)
return False
def get_font_path(self, font_name: str) -> Optional[str]:
if font_name == "default":
@@ -134,25 +376,12 @@ class RS_TextOverlay:
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)
@@ -161,7 +390,9 @@ class RS_TextOverlay:
if cache_key in self._font_cache:
return self._font_cache[cache_key]
try:
font = ImageFont.truetype(font_path, size)
font = ImageFont.truetype(font_path, size, layout_engine=ImageFont.Layout.BASIC)
font.path = font_path
font.size = size
self._font_cache[cache_key] = font
return font
except Exception as e:
@@ -170,27 +401,36 @@ class RS_TextOverlay:
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)
match = self._hex_color_pattern.match(color_str)
if match:
r, g, b = int(match.group(1), 16), int(match.group(2), 16), int(match.group(3), 16)
a = int(match.group(4), 16) if match.group(4) else int(alpha * 255)
result = (r, g, b, a)
else:
r, g, b, a = 255, 255, 255, int(alpha * 255)
result = (r, g, b, a)
match_short = self._hex_short_pattern.match(color_str)
if match_short:
r, g, b = int(match_short.group(1)*2, 16), int(match_short.group(2)*2, 16), int(match_short.group(3)*2, 16)
a = int(alpha * 255)
result = (r, g, b, a)
else:
result = (255, 255, 255, int(alpha * 255))
else:
result = (255, 255, 255, int(alpha * 255))
self._color_cache[cache_key] = result
return result
except:
except Exception as e:
self.log(f"Error parsing color {color}: {e}", level=2)
return (255, 255, 255, int(alpha * 255))
@functools.lru_cache(maxsize=64)
@@ -200,18 +440,26 @@ class RS_TextOverlay:
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()
font_path, size_str = font_key.rsplit(':', 1) if ':' in font_key else (font_key, '12')
try:
font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default()
except:
font = ImageFont.load_default()
lines = text.split('\n') 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)
@@ -223,8 +471,10 @@ class RS_TextOverlay:
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
@@ -241,21 +491,41 @@ class RS_TextOverlay:
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}"
def font_key(size):
return f"{font_path or 'default'}:{size}"
if self.text_fits(font_key(max_size), text, max_width, max_height,
config.letter_spacing, config.line_spacing,
config.outline_thickness, config.text_orientation):
return self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default(), max_size
try:
font = self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default()
if font_path and hasattr(font, 'path'):
font.path = font_path
font.size = max_size
return font, max_size
except:
pass
low, high = min_size, max_size
best_size, best_font = min_size, None
while low <= high:
iterations = 0
max_iterations = 50
while low <= high and iterations < max_iterations:
mid = (low + high) // 2
if pbar: pbar.update(1)
iterations += 1
if pbar:
pbar.update(1)
try:
font = self.get_cached_font(font_path, mid) if font_path else ImageFont.load_default()
except:
high = mid - 1
continue
if self.text_fits(font_key(mid), text, max_width, max_height,
config.letter_spacing, config.line_spacing,
config.outline_thickness, config.text_orientation):
@@ -263,12 +533,27 @@ class RS_TextOverlay:
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()
try:
best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default()
if font_path and hasattr(best_font, 'path'):
best_font.path = font_path
best_font.size = min_size
except:
best_font = ImageFont.load_default()
return best_font, best_size
def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray:
mask_hash = hash(mask_tensor.cpu().numpy().tobytes())
cached = self._mask_cache.get(mask_hash)
if cached is not None:
return cached
mask_np = mask_tensor.cpu().numpy()
if mask_np.ndim == 3:
if mask_np.shape[0] in [1, 3, 4]:
mask_np = mask_np[0]
@@ -281,40 +566,56 @@ class RS_TextOverlay:
mask_np = mask_np[0]
elif mask_np.shape[-1] in [1, 3, 4]:
mask_np = mask_np[:, :, 0]
if mask_np.dtype in [np.float32, np.float64]:
mask_np = (mask_np * 255).astype(np.uint8)
elif mask_np.dtype != np.uint8:
mask_np = mask_np.astype(np.uint8)
if mask_np.ndim != 2:
mask_np = mask_np.squeeze()
_, mask_np = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY)
self._mask_cache.put(mask_hash, mask_np)
return mask_np
def calculate_mask_rotation(self, mask_np: np.ndarray) -> float:
mask_hash = hash(mask_np.tobytes())
if mask_hash in self._contour_cache:
return self._contour_cache[mask_hash]
try:
contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return 0.0
contour = max(contours, key=cv2.contourArea)
if cv2.contourArea(contour) < 10:
area = cv2.contourArea(contour)
if area < 10:
return 0.0
rect = cv2.minAreaRect(contour)
angle = rect[2]
if angle < -45:
angle += 90
elif angle > 45:
angle -= 90
result = -angle
self._contour_cache[mask_hash] = result
return result
except:
except Exception as e:
self.log(f"Error calculating mask rotation: {e}", level=2)
return 0.0
def _draw_single_line(self, draw, position, text, font, color, letter_spacing, text_orientation):
x, y = position
if text_orientation == "horizontal":
current_x = x
for char in text:
@@ -327,152 +628,257 @@ class RS_TextOverlay:
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):
def _draw_outline_optimized(self, draw, position, text, font, color, thickness):
x, y = position
if thickness == 1:
directions = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
for dx, dy in directions:
self._draw_single_line(draw, (x + dx, y + dy), text, font, color, 0, "horizontal")
else:
for radius in range(1, thickness + 1):
for angle in range(0, 360, 45):
dx = int(radius * math.cos(math.radians(angle)))
dy = int(radius * math.sin(math.radians(angle)))
self._draw_single_line(draw, (x + dx, y + dy), text, font, color, 0, "horizontal")
def _render_text_core(self, text: str, config: TextRenderConfig,
container_size: Tuple[int, int]) -> Image.Image:
text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0))
draw = ImageDraw.Draw(text_layer)
total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions(
f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}",
text, config.letter_spacing, config.line_spacing,
config.outline_thickness, 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_outline_optimized(draw, (start_x, current_y), line, config.font,
config.outline_color, config.outline_thickness)
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):
def render_text_layer(self, text: str, config: TextRenderConfig,
container_size: Tuple[int, int]) -> Image.Image:
cache_key = f"{text}_{id(config)}_{container_size}_{config.font.size}"
cached = self._render_cache.get(cache_key)
if cached is not None:
return cached
text_layer = self._render_text_core(text, config, container_size)
if config.has_shadow:
text_layer = self._apply_shadow_effect(text_layer, config)
if config.use_supersampling and config.supersampling_factor > 1:
text_layer = self.quality_renderer.render_with_supersampling(
text, config, container_size, self._render_text_core, config.supersampling_factor
)
if config.use_edge_smoothing:
text_layer = self.quality_renderer.apply_edge_smoothing(text_layer)
self._render_cache.put(cache_key, text_layer)
return text_layer
def _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))
shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y),
Image.Resampling.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')
def _process_single(self, image, mask, text, font_name, config, padding,
vertical_align, horizontal_align, rotate_with_mask,
min_font_size, max_font_size):
with self.measure_time('mask_process_time'):
image_np = (image.cpu().numpy() * 255).astype(np.uint8)
image_pil = Image.fromarray(image_np, 'RGB') if image_np.ndim == 3 else Image.fromarray(image_np[0], 'RGB')
mask_np = self._prepare_mask_for_opencv(mask)
mask_pil = Image.fromarray(mask_np, 'L')
bbox = mask_pil.getbbox()
if not bbox:
return image
x1, y1, x2, y2 = bbox
available_w = max(1, x2 - x1 - 2 * padding)
available_h = max(1, y2 - y1 - 2 * padding)
font_path = self.get_font_path(font_name)
if font_path is None:
self.log(f"Font not found: {font_name}, using default", level=0)
font_path = self.get_font_path("default")
pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None
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)
with self.measure_time('font_find_time'):
config.font, actual_size = self.find_optimal_font_size(
font_path, text, available_w, available_h,
min_font_size, max_font_size, config, pbar
)
with self.measure_time('render_time'):
angle = self.calculate_mask_rotation(mask_np) if rotate_with_mask else 0
diag = math.sqrt(available_w**2 + available_h**2)
temp_size = int(diag * 1.2) + 50
text_layer = self.render_text_layer(text, config, (temp_size, temp_size))
if rotate_with_mask and abs(angle) > 0.1:
text_layer = text_layer.rotate(angle, center=(temp_size//2, temp_size//2),
resample=Image.Resampling.BICUBIC, expand=False)
rot_bbox = text_layer.getbbox()
if not rot_bbox:
return image
rx1, ry1, rx2, ry2 = rot_bbox
text_w, text_h = rx2 - rx1, ry2 - ry1
if rotate_with_mask:
paste_x = int((x1 + x2 - text_w) / 2)
paste_y = int((y1 + y2 - text_h) / 2)
else:
paste_x = {'left': x1 + padding, 'right': x2 - text_w - padding,
'center': int(x1 + (available_w - text_w) / 2 + padding)}[horizontal_align]
paste_y = {'top': y1 + padding, 'bottom': y2 - text_h - padding,
'center': int(y1 + (available_h - text_h) / 2 + padding)}[vertical_align]
cropped = text_layer.crop(rot_bbox)
image_rgba = image_pil.convert("RGBA")
overlay = Image.new("RGBA", image_rgba.size, (0, 0, 0, 0))
overlay.paste(cropped, (paste_x, paste_y), cropped)
result = Image.alpha_composite(image_rgba, overlay)
result_np = np.array(result.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(result_np).unsqueeze(0)
def apply_text(self, image: torch.Tensor, mask: torch.Tensor, node_data: str = None, **kwargs) -> Tuple[torch.Tensor]:
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,)
with self.measure_time('total_time'):
try:
if not self._validate_inputs(image, mask):
self.log("Input validation failed", level=0)
return (image,)
data = self._parse_and_validate_node_data(node_data)
text = data.get('text', 'Текст')
if not text or not text.strip():
self.log("Empty text, using default", level=1)
text = "Текст"
font_name = data.get('font_name', 'default')
self.log(f"Rendering text: '{text[:50]}' | Font: {font_name}", level=2)
text_color = data.get('text_color', '#FFFFFF')
outline_thickness = data.get('outline_thickness', 0)
outline_color = data.get('outline_color', '#808080')
rotate_with_mask = data.get('rotate_with_mask', True)
text_opacity = data.get('text_opacity', 1.0)
line_spacing = data.get('line_spacing', 1.0)
letter_spacing = data.get('letter_spacing', 0.0)
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)
use_supersampling = data.get('use_supersampling', False)
use_edge_smoothing = data.get('use_edge_smoothing', True)
supersampling_factor = data.get('supersampling_factor', 2)
min_font_size = data.get('min_font_size', 8)
max_font_size = data.get('max_font_size', 500)
padding = data.get('padding', 5)
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),
use_supersampling=use_supersampling,
use_edge_smoothing=use_edge_smoothing,
supersampling_factor=supersampling_factor
)
if image.ndim == 4:
results = []
for i in range(image.shape[0]):
result = self._process_single(
image[i], mask[i] if mask.ndim == 4 else mask,
text, font_name, config, padding, 'center', 'center',
rotate_with_mask, min_font_size, max_font_size
)
results.append(result)
result_tensor = torch.cat(results, dim=0)
else:
result_tensor = self._process_single(
image, mask, text, font_name, config, padding,
'center', 'center', rotate_with_mask, min_font_size, max_font_size
)
return (result_tensor,)
except Exception as e:
self.log(f"Error in apply_text: {type(e).__name__}: {e}", level=0)
import traceback
traceback.print_exc()
return (image,)
def __del__(self):
self._font_cache.clear()
self._color_cache.clear()
self._text_dim_cache.clear()
self._contour_cache.clear()
if self._render_cache:
self._render_cache.clear()
if hasattr(self, '_mask_cache'):
self._mask_cache.clear()
if torch.cuda.is_available():
torch.cuda.empty_cache()
NODE_CLASS_MAPPINGS = {"RS_TextOverlay": RS_TextOverlay}
NODE_DISPLAY_NAME_MAPPINGS = {"RS_TextOverlay": "🦊 RS Text Overlay"}
+75 -26
View File
@@ -1,15 +1,18 @@
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",
font_name: "default",
text_color: "#FFFFFF",
outline_thickness: 0,
outline_color: "#808080",
@@ -24,14 +27,19 @@ app.registerExtension({
shadow_offset_y: 2,
shadow_blur: 0.0,
shadow_opacity: 0.8,
font_list: ["default"]
font_list: ["default"],
use_supersampling: false,
use_edge_smoothing: true,
supersampling_factor: 2
};
this.rowHeight = 28;
this.padding = 10;
this.labelWidth = 120;
this.targetWidth = 350;
this.clickZones = [];
this.widgetYPositions = {};
this.hiddenWidget = this.widgets.find(w => w.name === "node_data");
if (this.hiddenWidget) {
this.hiddenWidget.hidden = true;
@@ -48,10 +56,12 @@ app.registerExtension({
console.error("🦊 [RS_TextOverlay] Error loading saved ", e);
}
}
if (this.widgets) {
this.widgets.forEach(w => w.hidden = true);
}
this.setSize([this.targetWidth, 650]);
this.setSize([this.targetWidth, 750]);
const self = this;
this.drawSeparator = function(ctx, text, x, y, w, h) {
@@ -77,23 +87,24 @@ app.registerExtension({
this.drawLabel(ctx, "TEXT", pad, y, labelW, rowH * 2);
this.drawMultilineField(ctx, this.data.text, pad + labelW, y, inputW, rowH * 2);
this.clickZones.push({ type: "text", x: pad + labelW, y: y, w: inputW, h: rowH * 2 });
this.widgetYPositions["text"] = y;
y += rowH * 2 + 5;
// === FONT_NAME ===
// === 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 ===
// === 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 ===
// === 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;
@@ -102,25 +113,25 @@ app.registerExtension({
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 ===
// === 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 ===
// === FORMATTING TEXT SEPARATOR ===
this.drawSeparator(ctx, "FORMATTING TEXT", pad, y, inputW + labelW, rowH);
y += rowH + 5;
// === ROTATE_WITH_MASK ===
// === 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 ===
// === 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;
@@ -129,7 +140,7 @@ app.registerExtension({
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 ===
// === 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;
@@ -138,7 +149,7 @@ app.registerExtension({
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 ===
// === 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;
@@ -147,32 +158,32 @@ app.registerExtension({
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 ===
// === 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);
// === SHADOW SETTINGS SEPARATOR ===
this.drawSeparator(ctx, "SHADOW SETTINGS", pad, y, inputW + labelW, rowH);
y += rowH + 5;
// === ENABLE_SHADOW ===
// === 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 ===
// === 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 ===
// === 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;
@@ -181,7 +192,7 @@ app.registerExtension({
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 ===
// === 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;
@@ -190,7 +201,7 @@ app.registerExtension({
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 ===
// === 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;
@@ -199,14 +210,43 @@ app.registerExtension({
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 ===
// === 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;
y += rowH + 5;
// === QUALITY SETTINGS SEPARATOR ===
this.drawSeparator(ctx, "QUALITY SETTINGS", pad, y, inputW + labelW, rowH);
y += rowH + 5;
// === USE SUPERSAMPLING ===
this.drawLabel(ctx, "SUPERSAMPLING", pad, y, labelW, rowH);
this.drawBooleanField(ctx, this.data.use_supersampling, pad + labelW, y, inputW, rowH);
this.widgetYPositions["use_supersampling"] = y;
this.clickZones.push({ type: "boolean", field: "use_supersampling", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
// === SUPERSAMPLING FACTOR ===
if (this.data.use_supersampling) {
this.drawLabel(ctx, "SUPERSAMPLE FACTOR", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.supersampling_factor, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["supersampling_factor"] = y;
this.clickZones.push({ type: "number", field: "supersampling_factor", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 2, max: 4 });
this.clickZones.push({ type: "arrow_left", field: "supersampling_factor", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 2, max: 4, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "supersampling_factor", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 2, max: 4, step: 1 });
y += rowH + 5;
}
// === USE EDGE SMOOTHING ===
this.drawLabel(ctx, "EDGE SMOOTHING", pad, y, labelW, rowH);
this.drawBooleanField(ctx, this.data.use_edge_smoothing, pad + labelW, y, inputW, rowH);
this.widgetYPositions["use_edge_smoothing"] = y;
this.clickZones.push({ type: "boolean", field: "use_edge_smoothing", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
const totalH = y + 10;
if (this.size[1] < totalH) {
@@ -375,12 +415,12 @@ app.registerExtension({
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] || [];
let list = options[fieldName] || [];
if (!list.length) return;
const menu = document.createElement("div");
menu.style.cssText = `
position: fixed;
@@ -393,6 +433,7 @@ app.registerExtension({
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;
@@ -415,9 +456,11 @@ app.registerExtension({
};
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;
@@ -427,7 +470,9 @@ app.registerExtension({
menu.style.left = "250px";
menu.style.top = "200px";
}
document.body.appendChild(menu);
setTimeout(() => {
const closeHandler = (e) => {
if (!menu.contains(e.target)) {
@@ -470,7 +515,7 @@ app.registerExtension({
`;
const saveBtn = document.createElement('button');
saveBtn.textContent = '✅';
saveBtn.textContent = '✅ SAVE';
saveBtn.style.cssText = `
background: #4CAF50;
color: #fff;
@@ -524,9 +569,11 @@ app.registerExtension({
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;
@@ -558,6 +605,7 @@ app.registerExtension({
}
});
}
const clickOutsideHandler = (e) => {
if (e.target !== colorInput) {
colorInput.remove();
@@ -600,4 +648,5 @@ app.registerExtension({
}
}
});
console.log("🦊 [RS_TextOverlay] Extension initialized");