Redesigned the logic of the node. The Glow function has been added.

This commit is contained in:
Raykosan
2026-03-29 20:30:01 +03:00
parent 4466cb2c1f
commit 268fd0300e
2 changed files with 291 additions and 716 deletions
+162 -257
View File
@@ -44,12 +44,16 @@ class TextRenderConfig:
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
shadow_opacity: float
enable_glow: bool = False
glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255)
glow_size: int = 150
glow_spread: int = 250
glow_brightness: float = 1.0
text_opacity: int = 100
use_supersampling: bool = False
use_edge_smoothing: bool = True
@@ -62,6 +66,10 @@ class TextRenderConfig:
@property
def has_shadow(self) -> bool:
return self.enable_shadow
@property
def has_glow(self) -> bool:
return self.enable_glow
class LRUCache:
def __init__(self, max_size: int = 100):
@@ -101,7 +109,6 @@ class HighQualityTextRenderer:
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,
@@ -110,7 +117,12 @@ class HighQualityTextRenderer:
shadow_opacity=config.shadow_opacity,
text_opacity=config.text_opacity,
use_supersampling=False,
use_edge_smoothing=False
use_edge_smoothing=False,
enable_glow=config.enable_glow,
glow_color=config.glow_color,
glow_size=config.glow_size * scale_factor,
glow_spread=config.glow_spread * scale_factor,
glow_brightness=config.glow_brightness
)
try:
@@ -150,8 +162,7 @@ class HighQualityTextRenderer:
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]]] = {}
_text_dim_cache: Dict[Tuple[str, str, float, float, int], Tuple[float, float, List[float], List[float]]] = {}
_contour_cache: Dict[int, float] = {}
_render_cache: LRUCache = None
@@ -162,13 +173,16 @@ class RS_TextOverlay:
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_list = []
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)
if not font_list:
font_list = ["Arial.ttf"]
return {
"required": {
"image": ("IMAGE",),
@@ -176,7 +190,7 @@ class RS_TextOverlay:
"node_data": ("STRING", {
"default": json.dumps({
"text": "Текст",
"font_name": "default",
"font_name": font_list[0],
"text_color": "#FFFFFF",
"outline_thickness": 0,
"outline_color": "#808080",
@@ -184,13 +198,17 @@ class RS_TextOverlay:
"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_offset_x": 10,
"shadow_offset_y": 10,
"shadow_blur": 0.2,
"shadow_opacity": 0.8,
"enable_glow": False,
"glow_color": "#FFFFFF",
"glow_size": 150,
"glow_spread": 250,
"glow_brightness": 1.0,
"font_list": font_list,
"use_supersampling": False,
"use_edge_smoothing": True,
@@ -222,6 +240,7 @@ class RS_TextOverlay:
}
self._mask_cache = LRUCache(max_size=50)
self._instance_color_cache: Dict[str, Tuple[int, int, int, int]] = {}
def log(self, message: str, level: int = 1):
if self.debug or level <= 0:
@@ -249,55 +268,49 @@ class RS_TextOverlay:
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"]
font_list = []
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)
if not font_list:
font_list = ["Arial.ttf"]
defaults = {
'text': 'Текст',
'font_name': 'default',
'font_name': font_list[0],
'text_color': '#FFFFFF',
'outline_thickness': 0,
'outline_color': '#808080',
@@ -305,13 +318,17 @@ class RS_TextOverlay:
'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_offset_x': 10,
'shadow_offset_y': 10,
'shadow_blur': 0.2,
'shadow_opacity': 0.8,
'enable_glow': False,
'glow_color': '#FFFFFF',
'glow_size': 150,
'glow_spread': 250,
'glow_brightness': 1.0,
'font_list': font_list,
'use_supersampling': False,
'use_edge_smoothing': True,
@@ -332,10 +349,13 @@ class RS_TextOverlay:
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_blur'] = max(0.0, min(1.0, data.get('shadow_blur', 0.2)))
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['shadow_offset_x'] = max(-500, min(500, data.get('shadow_offset_x', 10)))
data['shadow_offset_y'] = max(-500, min(500, data.get('shadow_offset_y', 10)))
data['glow_size'] = max(0, min(500, int(data.get('glow_size', 150))))
data['glow_spread'] = max(0, min(500, int(data.get('glow_spread', 250))))
data['glow_brightness'] = max(0.0, min(1.0, data.get('glow_brightness', 1.0)))
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)))
@@ -355,35 +375,23 @@ class RS_TextOverlay:
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":
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):
return local_path
return None
@functools.lru_cache(maxsize=32)
@@ -404,88 +412,62 @@ class RS_TextOverlay:
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()
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)
return (r, g, b, a)
else:
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)
return (r, g, b, a)
else:
result = (255, 255, 255, int(alpha * 255))
return (255, 255, 255, int(alpha * 255))
else:
result = (255, 255, 255, int(alpha * 255))
self._color_cache[cache_key] = result
return result
return (255, 255, 255, int(alpha * 255))
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)
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)
line_spacing: float, outline_thickness: int) -> Tuple[float, float, List[float], List[float]]:
cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness)
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) 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', '')]
lines = text.split('\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
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
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:
letter_spacing: float, line_spacing: float, outline_thickness: int) -> bool:
total_height, max_line_width, _, _ = self.calculate_text_dimensions(
font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation
font_key, text, letter_spacing, line_spacing, outline_thickness
)
return max_line_width <= max_width and total_height <= max_height
@@ -493,13 +475,11 @@ 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]:
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):
config.outline_thickness):
try:
font = self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default()
if font_path and hasattr(font, 'path'):
@@ -508,34 +488,27 @@ class RS_TextOverlay:
return font, max_size
except:
pass
low, high = min_size, max_size
best_size, best_font = min_size, None
iterations = 0
max_iterations = 50
while low <= high and iterations < max_iterations:
mid = (low + high) // 2
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):
config.outline_thickness):
best_font, best_size = font, mid
low = mid + 1
else:
high = mid - 1
if best_font is None:
try:
best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default()
@@ -544,18 +517,14 @@ class RS_TextOverlay:
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]
@@ -568,263 +537,195 @@ 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)
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 Exception as e:
self.log(f"Error calculating mask rotation: {e}", level=2)
return 0.0
# ✅ ИСПРАВЛЕННАЯ ФУНКЦИЯ — обводка с letter_spacing
def _draw_single_line(self, draw, position, text, font, color, letter_spacing, text_orientation):
def _draw_single_line(self, draw, position, text, font, color, letter_spacing):
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
# ✅ ИСПРАВЛЕННАЯ ФУНКЦИЯ — обводка рисуется для КАЖДОЙ буквы с letter_spacing
def _draw_outline_optimized(self, draw, position, text, font, color, thickness, letter_spacing, text_orientation):
x, y = position
if text_orientation == "horizontal":
current_x = x
for char in text:
# Рисуем обводку для каждой буквы
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:
draw.text((current_x + dx, y + dy), char, font=font, fill=color)
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)))
draw.text((current_x + dx, y + dy), char, font=font, fill=color)
# ✅ Применяем letter_spacing к обводке
current_x += draw.textlength(char, font=font) + letter_spacing
else:
current_y = y
for char in text:
# Рисуем обводку для каждой буквы
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:
draw.text((x + dx, current_y + dy), char, font=font, fill=color)
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)))
draw.text((x + dx, current_y + dy), char, font=font, fill=color)
bbox = draw.textbbox((x, current_y), char, font=font)
current_y += (bbox[3] - bbox[1]) + letter_spacing
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
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
config.outline_thickness
)
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', '')]
lines = text.split('\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
# ✅ ИСПРАВЛЕНИЕ: передаём letter_spacing в обводку
if config.has_outline:
self._draw_outline_optimized(draw, (start_x, current_y), line, config.font,
config.outline_color, config.outline_thickness,
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)
draw.text((start_x, current_y), line, font=config.font,
fill=config.text_color, stroke_width=config.outline_thickness,
stroke_fill=config.outline_color)
else:
self._draw_single_line(draw, (start_x, current_y), line, config.font,
config.text_color, config.letter_spacing)
current_y += line_h
return text_layer
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}"
def _remap_value(self, ui_value: float, min_actual: float = 0.7, max_actual: float = 1.0) -> float:
return min_actual + (ui_value * (max_actual - min_actual))
def _apply_glow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image:
if not config.has_glow or config.glow_size <= 0:
return text_layer
cached = self._render_cache.get(cache_key)
if cached is not None:
return cached
text_layer = self._render_text_core(text, config, container_size)
# Сначала supersampling
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.has_shadow:
text_layer = self._apply_shadow_effect(text_layer, config)
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
w, h = text_layer.size
alpha = np.array(text_layer.getchannel("A"), dtype=np.uint8)
_, binary_mask = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY)
binary_inv = cv2.bitwise_not(binary_mask)
distance = cv2.distanceTransform(binary_inv, cv2.DIST_L2, cv2.DIST_MASK_3)
distance = distance.astype(np.float32)
max_distance = float(config.glow_size)
if max_distance > 0:
glow_intensity = np.clip(1.0 - (distance / max_distance), 0, 1)
else:
glow_intensity = np.zeros_like(distance)
if config.glow_spread > 0:
glow_zone = binary_mask.copy()
kernel_size = config.glow_spread * 2 + 1
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
glow_zone = cv2.dilate(glow_zone, kernel, iterations=1)
glow_zone = cv2.subtract(glow_zone, binary_mask)
glow_zone_float = glow_zone.astype(np.float32) / 255.0
glow_intensity = glow_intensity * glow_zone_float
actual_brightness = self._remap_value(config.glow_brightness, 0.7, 1.0)
glow_intensity = glow_intensity * actual_brightness
glow_arr = np.clip(glow_intensity * 255, 0, 255).astype(np.uint8)
glow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
glow_rgba[:, :, 0] = config.glow_color[0]
glow_rgba[:, :, 1] = config.glow_color[1]
glow_rgba[:, :, 2] = config.glow_color[2]
glow_rgba[:, :, 3] = glow_arr
glow_colored = Image.fromarray(glow_rgba, 'RGBA')
result = Image.new("RGBA", (w, h), (0, 0, 0, 0))
result.paste(glow_colored, (0, 0), glow_colored)
result.paste(text_layer, (0, 0), text_layer)
return result
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, (0, 0, 0, 0))
shadow.putalpha(alpha.copy())
# Применяем размытие если нужно
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
shadow = shadow.transform(shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y),
Image.Resampling.BICUBIC, fill=(0, 0, 0, 0))
# Применяем цвет и opacity тени
shadow_data = np.array(shadow)
mask = shadow_data[:, :, 3] > 0
# Применяем shadow_opacity к альфа-каналу
shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * config.shadow_opacity).astype(np.uint8)
# Создаём цвет тени (RGB без alpha)
shadow_rgb = np.array([
config.shadow_color[0],
config.shadow_color[1],
config.shadow_color[2]
], dtype=np.uint8)
# Применяем цвет только к пикселям где есть тень
actual_shadow_opacity = self._remap_value(config.shadow_opacity, 0.7, 1.0)
shadow_data[:, :, 3] = (shadow_data[:, :, 3].astype(np.float32) * actual_shadow_opacity).astype(np.uint8)
shadow_rgb = np.array([config.shadow_color[0], config.shadow_color[1], config.shadow_color[2]], dtype=np.uint8)
shadow_data[mask, :3] = shadow_rgb
shadow_colored = Image.fromarray(shadow_data, 'RGBA')
# Композит: сначала тень, потом текст
result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0))
result.paste(shadow_colored, (0, 0), shadow_colored)
result.paste(text_layer, (0, 0), text_layer)
return result
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.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.has_glow:
text_layer = self._apply_glow_effect(text_layer, config)
if config.has_shadow:
text_layer = self._apply_shadow_effect(text_layer, config)
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 _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
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)
@@ -833,34 +734,33 @@ class RS_TextOverlay:
'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]:
with self.measure_time('total_time'):
try:
self._instance_color_cache.clear()
if self._render_cache:
self._render_cache.clear()
self._mask_cache.clear()
self._text_dim_cache.clear()
self._contour_cache.clear()
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')
@@ -868,13 +768,17 @@ class RS_TextOverlay:
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_offset_x = data.get('shadow_offset_x', 10)
shadow_offset_y = data.get('shadow_offset_y', 10)
shadow_blur = data.get('shadow_blur', 0.2)
shadow_opacity = data.get('shadow_opacity', 0.8)
enable_glow = data.get('enable_glow', False)
glow_color = data.get('glow_color', '#FFFFFF')
glow_size = data.get('glow_size', 150)
glow_spread = data.get('glow_spread', 250)
glow_brightness = data.get('glow_brightness', 1.0)
use_supersampling = data.get('use_supersampling', False)
use_edge_smoothing = data.get('use_edge_smoothing', True)
supersampling_factor = data.get('supersampling_factor', 2)
@@ -882,27 +786,30 @@ class RS_TextOverlay:
max_font_size = data.get('max_font_size', 500)
padding = data.get('padding', 5)
self.log(f"Shadow settings: enable={enable_shadow}, color={shadow_color}, offset=({shadow_offset_x}, {shadow_offset_y}), blur={shadow_blur}, opacity={shadow_opacity}", level=1)
actual_text_opacity = self._remap_value(text_opacity, 0.7, 1.0)
config = TextRenderConfig(
font=None,
text_color=self.parse_color(text_color, text_opacity),
outline_color=self.parse_color(outline_color, text_opacity),
text_color=self.parse_color(text_color, actual_text_opacity),
outline_color=self.parse_color(outline_color, actual_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, 1.0),
shadow_offset=(shadow_offset_x, shadow_offset_y),
shadow_blur=shadow_blur,
shadow_opacity=shadow_opacity,
text_opacity=int(text_opacity * 100),
enable_glow=enable_glow,
glow_color=self.parse_color(glow_color, 1.0),
glow_size=glow_size,
glow_spread=glow_spread,
glow_brightness=glow_brightness,
text_opacity=int(actual_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]):
@@ -918,9 +825,7 @@ class RS_TextOverlay:
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
@@ -929,14 +834,14 @@ class RS_TextOverlay:
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 hasattr(self, '_instance_color_cache'):
self._instance_color_cache.clear()
if torch.cuda.is_available():
torch.cuda.empty_cache()
+129 -459
View File
@@ -4,8 +4,6 @@ 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;
@@ -20,13 +18,17 @@ app.registerExtension({
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_offset_x: 10,
shadow_offset_y: 10,
shadow_blur: 0.2,
shadow_opacity: 0.8,
enable_glow: false,
glow_color: "#FFFFFF",
glow_size: 150,
glow_spread: 250,
glow_brightness: 1.0,
font_list: ["default"],
use_supersampling: false,
use_edge_smoothing: true,
@@ -53,7 +55,7 @@ app.registerExtension({
this.data = { ...this.data, ...saved };
}
} catch (e) {
console.error("🦊 [RS_TextOverlay] Error loading saved data", e);
console.error("Error loading saved data", e);
}
}
@@ -83,28 +85,24 @@ app.registerExtension({
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 });
this.widgetYPositions["text"] = y;
y += rowH * 2 + 5;
// FONT
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;
@@ -113,25 +111,21 @@ 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
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 SEPARATOR
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;
@@ -140,7 +134,6 @@ 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
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;
@@ -149,7 +142,6 @@ 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
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;
@@ -158,50 +150,76 @@ 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
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 });
this.drawSeparator(ctx, "GLOW SETTINGS", pad, y, inputW + labelW, rowH);
y += rowH + 5;
this.drawLabel(ctx, "ENABLE GLOW", pad, y, labelW, rowH);
this.drawBooleanField(ctx, this.data.enable_glow, pad + labelW, y, inputW, rowH);
this.widgetYPositions["enable_glow"] = y;
this.clickZones.push({ type: "boolean", field: "enable_glow", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
this.drawLabel(ctx, "GLOW COLOR", pad, y, labelW, rowH);
this.drawColorField(ctx, this.data.glow_color, pad + labelW, y, inputW, rowH);
this.widgetYPositions["glow_color"] = y;
this.clickZones.push({ type: "color", field: "glow_color", x: pad + labelW, y: y, w: inputW, h: rowH });
y += rowH + 5;
this.drawLabel(ctx, "GLOW SIZE", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.glow_size, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["glow_size"] = y;
this.clickZones.push({ type: "number", field: "glow_size", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 500 });
this.clickZones.push({ type: "arrow_left", field: "glow_size", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "glow_size", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
y += rowH + 5;
this.drawLabel(ctx, "GLOW SPREAD", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.glow_spread, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["glow_spread"] = y;
this.clickZones.push({ type: "number", field: "glow_spread", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: 0, max: 500 });
this.clickZones.push({ type: "arrow_left", field: "glow_spread", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "glow_spread", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 500, step: 1 });
y += rowH + 5;
this.drawLabel(ctx, "GLOW BRIGHTNESS", pad, y, labelW, rowH);
this.drawNumberFieldWithArrows(ctx, this.data.glow_brightness, pad + labelW, y, inputW, rowH, arrowW);
this.widgetYPositions["glow_brightness"] = y;
this.clickZones.push({ type: "number", field: "glow_brightness", 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: "glow_brightness", x: pad + labelW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.02, float: true });
this.clickZones.push({ type: "arrow_right", field: "glow_brightness", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: 0, max: 1, step: 0.02, float: true });
y += rowH + 5;
// SHADOW SETTINGS SEPARATOR
this.drawSeparator(ctx, "SHADOW SETTINGS", 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 });
this.clickZones.push({ type: "number", field: "shadow_offset_x", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -500, max: 500 });
this.clickZones.push({ type: "arrow_left", field: "shadow_offset_x", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "shadow_offset_x", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -500, max: 500, 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 });
this.clickZones.push({ type: "number", field: "shadow_offset_y", x: pad + labelW + arrowW, y: y, w: inputW - arrowW*2, h: rowH, min: -500, max: 500 });
this.clickZones.push({ type: "arrow_left", field: "shadow_offset_y", x: pad + labelW, y: y, w: arrowW, h: rowH, min: -500, max: 500, step: 1 });
this.clickZones.push({ type: "arrow_right", field: "shadow_offset_y", x: pad + labelW + inputW - arrowW, y: y, w: arrowW, h: rowH, min: -500, max: 500, 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;
@@ -210,8 +228,7 @@ 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
this.drawLabel(ctx, "SHADOW OPACITY", pad, y, labelW, rowH);
this.drawLabel(ctx, "SHADOW BRIGHTNESS", 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 });
@@ -219,18 +236,15 @@ app.registerExtension({
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 + 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);
@@ -241,7 +255,6 @@ app.registerExtension({
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;
@@ -256,36 +269,16 @@ app.registerExtension({
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(e);
return true;
}
if (zone.type === "combo") {
self.showComboSelector(zone.field, e);
return true;
}
if (zone.type === "color") {
self.openColorPicker(zone.field, e);
return true;
}
if (zone.type === "boolean") {
self.data[zone.field] = !self.data[zone.field];
self.updateUI();
return true;
}
if (zone.type === "text") { self.showTextInput(e); return true; }
if (zone.type === "combo") { self.showComboSelector(zone.field, e); return true; }
if (zone.type === "color") { self.openColorPicker(zone.field, e); return true; }
if (zone.type === "boolean") { self.data[zone.field] = !self.data[zone.field]; self.updateUI(); return true; }
if (zone.type === "number") {
self.showInlineInput(zone.field, e, {
isNumber: true,
min: zone.min,
max: zone.max,
step: zone.step || 1,
float: zone.float || false
});
self.showInlineInput(zone.field, e, { isNumber: true, min: zone.min, max: zone.max, step: zone.step || 1, float: zone.float || false });
return true;
}
if (zone.type === "arrow_left") {
@@ -309,446 +302,123 @@ app.registerExtension({
return false;
};
// === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ОТРИСОВКИ ===
this.drawLabel = function(ctx, text, x, y, w, h) {
ctx.fillStyle = "#aaa";
ctx.font = "11px sans-serif";
ctx.textAlign = "left";
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";
if (!value || value.trim() === "") {
ctx.fillStyle = "#666";
ctx.fillText("Enter text...", x + 5, y + h/2 + 4);
return;
}
const lines = value.split('\n');
const lineHeight = 14;
const startY = y + 10;
const maxLines = 3;
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";
if (!value || value.trim() === "") { ctx.fillStyle = "#666"; ctx.fillText("Enter text...", x + 5, y + h/2 + 4); return; }
const lines = value.split('\n'), lineHeight = 14, startY = y + 10, maxLines = 3;
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
let line = lines[i];
if (line.length > 35) {
line = line.substring(0, 32) + "...";
}
let line = lines[i]; if (line.length > 35) line = line.substring(0, 32) + "...";
ctx.fillText(line, x + 5, startY + (i * lineHeight));
}
if (lines.length > maxLines) {
ctx.fillStyle = "#666";
ctx.fillText("...", x + 5, startY + (maxLines * lineHeight));
}
if (lines.length > maxLines) { ctx.fillStyle = "#666"; ctx.fillText("...", x + 5, startY + (maxLines * lineHeight)); }
};
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.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();
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);
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, colorBoxY = y, colorBoxH = h;
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";
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.fillStyle = "#222"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#444"; ctx.strokeRect(x, y, w, h);
const circleX = x + w - 35, 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);
};
// === POPUP: ВЫПАДАЮЩИЙ СПИСОК — ПОЗИЦИЯ СПРАВА ОТ КЛИКА ===
this.showComboSelector = function(fieldName, clickEvent) {
const options = {
font_name: self.data.font_list || ["default"],
text_orientation: ["horizontal", "vertical"]
};
let list = options[fieldName] || [];
if (!list.length) return;
const options = { font_name: self.data.font_list || ["default"] };
let 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;
`;
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 = (ev) => {
ev.stopPropagation();
ev.preventDefault();
self.data[fieldName] = opt;
self.updateUI();
menu.remove();
};
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 = (ev) => { ev.stopPropagation(); ev.preventDefault(); self.data[fieldName] = opt; self.updateUI(); menu.remove(); };
menu.appendChild(item);
});
if (clickEvent) {
menu.style.left = (clickEvent.clientX + 8) + "px";
menu.style.top = clickEvent.clientY + "px";
} else {
menu.style.left = "250px";
menu.style.top = "200px";
}
if (clickEvent) { menu.style.left = (clickEvent.clientX + 8) + "px"; menu.style.top = clickEvent.clientY + "px"; }
else { menu.style.left = "250px"; menu.style.top = "200px"; }
document.body.appendChild(menu);
setTimeout(() => {
const closeHandler = (ev) => {
if (!menu.contains(ev.target)) {
menu.remove();
document.removeEventListener("mousedown", closeHandler);
}
};
document.addEventListener("mousedown", closeHandler);
}, 100);
setTimeout(() => { const closeHandler = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 100);
};
// === POPUP: МНОГОСТРОЧНЫЙ ТЕКСТ — С ФОКУСОМ И ВЫДЕЛЕНИЕМ ===
this.showTextInput = function(clickEvent) {
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 = '✅ SAVE';
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);
if (clickEvent) {
popup.style.left = (clickEvent.clientX + 8) + 'px';
popup.style.top = clickEvent.clientY + 'px';
}
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 = 'SAVE';
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);
if (clickEvent) { popup.style.left = (clickEvent.clientX + 8) + 'px'; popup.style.top = clickEvent.clientY + 'px'; }
document.body.appendChild(popup);
setTimeout(() => {
input.focus();
setTimeout(() => {
if (currentValue && currentValue.length > 0) {
input.select();
}
}, 10);
}, 50);
saveBtn.onclick = (ev) => {
ev.stopPropagation();
ev.preventDefault();
self.data.text = input.value;
self.updateUI();
popup.remove();
};
input.onkeydown = (ev) => {
if (ev.key === 'Enter' && ev.ctrlKey) {
ev.preventDefault();
self.data.text = input.value;
self.updateUI();
popup.remove();
}
};
setTimeout(() => { input.focus(); setTimeout(() => { if (currentValue && currentValue.length > 0) input.select(); }, 10); }, 50);
saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); self.data.text = input.value; self.updateUI(); popup.remove(); };
input.onkeydown = (ev) => { if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); self.data.text = input.value; self.updateUI(); popup.remove(); } };
};
// === POPUP: ОДНОСТРОЧНЫЙ ВВОД — С ФОКУСОМ И ВЫДЕЛЕНИЕМ ===
this.showInlineInput = function(fieldName, clickEvent, options = {}) {
const currentValue = self.data[fieldName] || '';
const isNumber = options.isNumber || false;
const min = options.min !== undefined ? options.min : null;
const max = options.max !== undefined ? options.max : null;
const isFloat = options.float || false;
const isNumber = options.isNumber || false, min = options.min !== undefined ? options.min : null, max = options.max !== undefined ? options.max : null, isFloat = options.float || false;
const popup = document.createElement('div');
popup.style.cssText = `
position: fixed;
z-index: 10003;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
padding: 8px 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
`;
const input = document.createElement('input');
input.type = isNumber ? 'number' : 'text';
input.value = currentValue;
if (isNumber) {
input.step = options.step || 1;
if (min !== null) input.min = min;
if (max !== null) input.max = max;
}
input.style.cssText = `
width: 100px;
background: #222;
color: #fff;
border: 1px solid #444;
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
outline: none;
`;
const saveBtn = document.createElement('button');
saveBtn.textContent = '✅';
saveBtn.title = 'Save';
saveBtn.style.cssText = `
background: #4CAF50;
color: #fff;
border: none;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
min-width: 28px;
`;
saveBtn.onmouseover = () => saveBtn.style.background = "#45a049";
saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50";
popup.appendChild(input);
popup.appendChild(saveBtn);
if (clickEvent) {
popup.style.left = (clickEvent.clientX + 8) + 'px';
popup.style.top = clickEvent.clientY + 'px';
}
popup.style.cssText = `position:fixed;z-index:10003;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;white-space:nowrap;`;
const input = document.createElement('input'); input.type = isNumber ? 'number' : 'text'; input.value = currentValue;
if (isNumber) { input.step = options.step || 1; if (min !== null) input.min = min; if (max !== null) input.max = max; }
input.style.cssText = `width:100px;background:#222;color:#fff;border:1px solid #444;border-radius:4px;padding:6px 10px;font-size:12px;outline:none;`;
const saveBtn = document.createElement('button'); saveBtn.textContent = 'OK'; saveBtn.title = 'Save';
saveBtn.style.cssText = `background:#4CAF50;color:#fff;border:none;border-radius:4px;padding:6px 12px;font-size:12px;cursor:pointer;min-width:28px;`;
saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50";
popup.appendChild(input); popup.appendChild(saveBtn);
if (clickEvent) { popup.style.left = (clickEvent.clientX + 8) + 'px'; popup.style.top = clickEvent.clientY + 'px'; }
document.body.appendChild(popup);
setTimeout(() => {
input.focus();
setTimeout(() => {
if (currentValue !== null && currentValue !== undefined && String(currentValue).length > 0) {
input.select();
}
}, 10);
}, 50);
const doSave = () => {
let value = input.value;
if (isNumber) {
let num = isFloat ? parseFloat(value) : parseInt(value);
if (isNaN(num)) num = self.data[fieldName];
if (min !== null) num = Math.max(min, num);
if (max !== null) num = Math.min(max, num);
value = isFloat ? Math.round(num * 100) / 100 : num;
}
self.data[fieldName] = value;
self.updateUI();
popup.remove();
};
setTimeout(() => { input.focus(); setTimeout(() => { if (currentValue !== null && currentValue !== undefined && String(currentValue).length > 0) input.select(); }, 10); }, 50);
const doSave = () => { let value = input.value; if (isNumber) { let num = isFloat ? parseFloat(value) : parseInt(value); if (isNaN(num)) num = self.data[fieldName]; if (min !== null) num = Math.max(min, num); if (max !== null) num = Math.min(max, num); value = isFloat ? Math.round(num * 100) / 100 : num; } self.data[fieldName] = value; self.updateUI(); popup.remove(); };
saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); };
input.onkeydown = (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
doSave();
}
};
setTimeout(() => {
const closeHandler = (ev) => {
if (!popup.contains(ev.target)) {
popup.remove();
document.removeEventListener("mousedown", closeHandler);
}
};
document.addEventListener("mousedown", closeHandler);
}, 50);
input.onkeydown = (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSave(); } };
setTimeout(() => { const closeHandler = (ev) => { if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener("mousedown", closeHandler); } }; document.addEventListener("mousedown", closeHandler); }, 50);
return popup;
};
// === НАТИВНЫЙ КОЛОРПИКЕР — ПРОСТОЙ И НАДЁЖНЫЙ ===
this.openColorPicker = function(fieldName, clickEvent) {
const currentColor = self.data[fieldName] || '#FFFFFF';
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = currentColor;
colorInput.style.display = 'none';
const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = currentColor; colorInput.style.display = 'none';
document.body.appendChild(colorInput);
colorInput.addEventListener('change', (e) => {
const newColor = e.target.value.toUpperCase();
self.data[fieldName] = newColor;
self.updateUI();
colorInput.remove();
}, { once: true });
// Открываем нативный пикер
setTimeout(() => {
if (colorInput.showPicker) {
colorInput.showPicker();
} else {
colorInput.click();
}
}, 10);
};
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;
colorInput.addEventListener('change', (e) => { const newColor = e.target.value.toUpperCase(); self.data[fieldName] = newColor; self.updateUI(); colorInput.remove(); }, { once: true });
setTimeout(() => { if (colorInput.showPicker) colorInput.showPicker(); else colorInput.click(); }, 10);
};
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");
});