1279 lines
50 KiB
Python
1279 lines
50 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from scipy import ndimage
|
|
import os
|
|
import torch
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
import functools
|
|
from typing import Dict, Tuple, List, Optional, Any
|
|
from dataclasses import dataclass
|
|
import time
|
|
import re
|
|
from collections import OrderedDict
|
|
import folder_paths
|
|
from server import PromptServer
|
|
from aiohttp import web
|
|
import freetype
|
|
import cairo
|
|
import cv2
|
|
import json
|
|
import asyncio
|
|
|
|
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()
|
|
|
|
TRANSFORM_CACHE = LRUCache(max_size=50)
|
|
PENDING_TRANSFORMS = LRUCache(max_size=50)
|
|
RENDER_CACHE = LRUCache(max_size=100)
|
|
|
|
GLOBAL_RENDERER = None
|
|
|
|
def get_renderer():
|
|
global GLOBAL_RENDERER
|
|
if GLOBAL_RENDERER is None:
|
|
GLOBAL_RENDERER = RS_OverlayPro()
|
|
return GLOBAL_RENDERER
|
|
|
|
def safe_font_path(font_name: str) -> Optional[str]:
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
fonts_dir = os.path.join(current_dir, "fonts")
|
|
if os.path.basename(font_name) != font_name:
|
|
return None
|
|
path = os.path.join(fonts_dir, font_name)
|
|
if os.path.isfile(path):
|
|
return path
|
|
return None
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro")
|
|
async def transform_handler(request):
|
|
try:
|
|
data = await request.json()
|
|
node_id = str(data.get("id"))
|
|
transforms = data.get("transforms")
|
|
text_params = data.get("text_params", {})
|
|
|
|
if node_id is None or transforms is None:
|
|
return web.json_response({"error": "Missing data"}, status=400)
|
|
|
|
required = {"x", "y", "rotation", "base_width_px", "base_height_px"}
|
|
if not all(k in transforms for k in required):
|
|
return web.json_response({"error": "Invalid transform data"}, status=400)
|
|
|
|
if not isinstance(text_params, dict):
|
|
return web.json_response({"error": "text_params must be object"}, status=400)
|
|
|
|
TRANSFORM_CACHE.put(node_id, {
|
|
**transforms,
|
|
"text_params": text_params
|
|
})
|
|
|
|
if node_id in PENDING_TRANSFORMS.cache:
|
|
PENDING_TRANSFORMS.cache[node_id]["status"] = "completed"
|
|
|
|
return web.json_response({"status": "ok"})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.get("/rayko/rs_overlay_pro/get_fonts")
|
|
async def get_fonts_handler(request):
|
|
try:
|
|
renderer = get_renderer()
|
|
font_list = renderer._get_font_list()
|
|
return web.json_response({"font_list": font_list, "default_font": font_list[0] if font_list else ""})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cancel")
|
|
async def cancel_handler(request):
|
|
try:
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id"))
|
|
if node_id:
|
|
PENDING_TRANSFORMS.cache[node_id] = {"status": "cancelled"}
|
|
return web.json_response({"status": "cancelled"})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/cleanup")
|
|
async def cleanup_handler(request):
|
|
try:
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id"))
|
|
TRANSFORM_CACHE.cache.pop(node_id, None)
|
|
PENDING_TRANSFORMS.cache.pop(node_id, None)
|
|
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
for f in os.listdir(temp_dir):
|
|
if f.startswith(f"rs_overlay_pro_{node_id}_"):
|
|
try:
|
|
os.remove(os.path.join(temp_dir, f))
|
|
except:
|
|
pass
|
|
|
|
return web.json_response({"status": "ok"})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_masks")
|
|
async def render_masks_handler(request):
|
|
try:
|
|
data = await request.json()
|
|
text_params = data.get("text_params", {})
|
|
container_width = int(data.get("container_width", 400))
|
|
container_height = int(data.get("container_height", 200))
|
|
node_id = str(data.get("node_id", "unknown"))
|
|
|
|
if container_width <= 0 or container_height <= 0:
|
|
return web.json_response({"error": "Invalid container size"}, status=400)
|
|
|
|
renderer = get_renderer()
|
|
masks, render_size = renderer.render_masks_from_params(text_params, (container_width, container_height))
|
|
|
|
ts = int(time.time() * 1000)
|
|
result = {
|
|
"width": render_size[0],
|
|
"height": render_size[1],
|
|
"timestamp": ts
|
|
}
|
|
|
|
for mask_name, mask_img in masks.items():
|
|
filename = f"rs_overlay_pro_{node_id}_{ts}_{mask_name}.png"
|
|
filepath = os.path.join(folder_paths.get_temp_directory(), filename)
|
|
mask_img.save(filepath)
|
|
result[f"{mask_name}_file"] = filename
|
|
|
|
return web.json_response(result)
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_text")
|
|
async def render_text_handler(request):
|
|
try:
|
|
data = await request.json()
|
|
text_params = data.get("text_params", {})
|
|
container_width = int(data.get("container_width", 400))
|
|
container_height = int(data.get("container_height", 200))
|
|
node_id = str(data.get("node_id", "unknown"))
|
|
|
|
if container_width <= 0 or container_height <= 0:
|
|
return web.json_response({"error": "Invalid container size"}, status=400)
|
|
|
|
renderer = get_renderer()
|
|
text_layer = renderer.render_text_from_params(text_params, (container_width, container_height))
|
|
|
|
ts = int(time.time() * 1000)
|
|
filename = f"rs_overlay_pro_{node_id}_{ts}_text.png"
|
|
filepath = os.path.join(folder_paths.get_temp_directory(), filename)
|
|
text_layer.save(filepath)
|
|
|
|
return web.json_response({
|
|
"filename": filename,
|
|
"width": text_layer.width,
|
|
"height": text_layer.height,
|
|
"timestamp": ts
|
|
})
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_glow_mask")
|
|
async def render_glow_mask_handler(request):
|
|
try:
|
|
data = await request.json()
|
|
text_mask_file = data.get("text_mask_file")
|
|
glow_size = int(data.get("glow_size", 0))
|
|
glow_spread = int(data.get("glow_spread", 0))
|
|
glow_opacity = float(data.get("glow_opacity", 1.0))
|
|
node_id = str(data.get("node_id", "unknown"))
|
|
|
|
if not text_mask_file:
|
|
return web.json_response({"error": "Missing text_mask_file"}, status=400)
|
|
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
mask_path = os.path.join(temp_dir, text_mask_file)
|
|
|
|
if not os.path.isfile(mask_path):
|
|
return web.json_response({"error": "text_mask_file not found"}, status=404)
|
|
|
|
cache_key = f"{text_mask_file}_{glow_size}_{glow_spread}_{glow_opacity}"
|
|
cached = RENDER_CACHE.get(cache_key)
|
|
if cached:
|
|
return web.json_response(cached)
|
|
|
|
text_mask = Image.open(mask_path).convert("RGBA")
|
|
w, h = text_mask.size
|
|
|
|
extra_padding = 0
|
|
if glow_size > 0:
|
|
extra_padding = int(glow_size * 1.0 + glow_spread * 0.3) + 10
|
|
extra_padding = min(extra_padding, 300)
|
|
|
|
expanded_w = w + 2 * extra_padding
|
|
expanded_h = h + 2 * extra_padding
|
|
expanded_mask = Image.new("RGBA", (expanded_w, expanded_h), (0, 0, 0, 0))
|
|
paste_x = extra_padding
|
|
paste_y = extra_padding
|
|
expanded_mask.paste(text_mask, (paste_x, paste_y), text_mask)
|
|
|
|
renderer = get_renderer()
|
|
glow_mask = renderer._apply_glow_to_mask(
|
|
expanded_mask, glow_size, glow_spread, glow_opacity
|
|
)
|
|
|
|
ts = int(time.time() * 1000)
|
|
filename = f"rs_overlay_pro_{node_id}_{ts}_glow.png"
|
|
filepath = os.path.join(temp_dir, filename)
|
|
glow_mask.save(filepath)
|
|
|
|
response_data = {
|
|
"glow_file": filename,
|
|
"width": glow_mask.width,
|
|
"height": glow_mask.height,
|
|
"timestamp": ts,
|
|
"extra_padding": extra_padding
|
|
}
|
|
RENDER_CACHE.put(cache_key, response_data)
|
|
return web.json_response(response_data)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@PromptServer.instance.routes.post("/rayko/rs_overlay_pro/render_shadow_mask")
|
|
async def render_shadow_mask(request):
|
|
try:
|
|
data = await request.json()
|
|
text_mask_file = data.get("text_mask_file")
|
|
shadow_blur = int(data.get("shadow_blur", 0))
|
|
node_id = data.get("node_id", "0")
|
|
|
|
if not text_mask_file:
|
|
return web.json_response({"error": "text_mask_file is required"}, status=400)
|
|
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
text_mask_path = os.path.join(temp_dir, text_mask_file)
|
|
|
|
if not os.path.exists(text_mask_path):
|
|
print(f"[RS Overlay Pro] ERROR: file does not exist at {text_mask_path}")
|
|
return web.json_response({"error": "text_mask_file does not exist"}, status=404)
|
|
|
|
img = Image.open(text_mask_path).convert("L")
|
|
|
|
if shadow_blur > 0:
|
|
from PIL import ImageFilter
|
|
img = img.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
|
|
|
|
rgba = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
rgba.putalpha(img)
|
|
|
|
timestamp = int(time.time() * 1000)
|
|
out_name = f"shadow_{node_id}_{timestamp}.png"
|
|
out_path = os.path.join(temp_dir, out_name)
|
|
rgba.save(out_path, optimize=True)
|
|
|
|
return web.json_response({
|
|
"shadow_file": out_name,
|
|
"timestamp": timestamp
|
|
})
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
@dataclass
|
|
class TextRenderConfig:
|
|
font: ImageFont.ImageFont
|
|
font_path: str
|
|
font_size: int
|
|
text_color: Tuple[int, int, int, int]
|
|
outline_color: Tuple[int, int, int, int]
|
|
outline_thickness: int
|
|
text_opacity: int = 100
|
|
outline_opacity: int = 100
|
|
effect_mode: str = "on"
|
|
text_scale_x: float = 1.0
|
|
text_scale_y: float = 1.0
|
|
text_align: str = "left"
|
|
line_spacing: float = 1.0
|
|
letter_spacing: float = 0.0
|
|
enable_glow: bool = False
|
|
glow_color: Tuple[int, int, int, int] = (255, 255, 255, 255)
|
|
glow_size: int = 100
|
|
glow_spread: int = 150
|
|
glow_opacity: float = 1.0
|
|
enable_shadow: bool = False
|
|
shadow_color: Tuple[int, int, int, int] = (51, 51, 51, 255)
|
|
shadow_offset_x: int = 10
|
|
shadow_offset_y: int = 10
|
|
shadow_opacity: float = 0.8
|
|
shadow_blur: int = 0
|
|
|
|
@property
|
|
def has_outline(self) -> bool:
|
|
return self.effect_mode == "on" and self.outline_thickness > 0
|
|
|
|
class HybridTextRenderer:
|
|
@staticmethod
|
|
def render_text_mask_pillow(font_path: str, font_size: int, text: str,
|
|
container_size: Tuple[int, int], text_align: str = "left",
|
|
padding_x: int = 20, padding_y: int = 25,
|
|
line_spacing: float = 1.0,
|
|
letter_spacing: float = 0.0) -> Image.Image:
|
|
width, height = container_size
|
|
text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(text_layer)
|
|
|
|
try:
|
|
font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC)
|
|
except:
|
|
font = ImageFont.load_default()
|
|
|
|
lines = text.split('\n')
|
|
if not lines:
|
|
return text_layer
|
|
|
|
first_bbox = draw.textbbox((0, 0), lines[0], font=font)
|
|
line_height = first_bbox[3] - first_bbox[1]
|
|
if line_height == 0:
|
|
line_height = font_size
|
|
|
|
total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1)
|
|
available_height = height - 2 * padding_y
|
|
y_start = padding_y + (available_height - total_text_height) / 2
|
|
baseline_offset = -first_bbox[1]
|
|
y_start += baseline_offset
|
|
|
|
current_y = y_start
|
|
|
|
for line in lines:
|
|
if not line:
|
|
current_y += line_height * line_spacing
|
|
continue
|
|
|
|
line_bbox = draw.textbbox((0, 0), line, font=font)
|
|
line_width = line_bbox[2] - line_bbox[0]
|
|
if letter_spacing != 0.0 and len(line) > 1:
|
|
line_width += letter_spacing * (len(line) - 1)
|
|
|
|
available_width = width - 2 * padding_x
|
|
if text_align == "left":
|
|
x = padding_x - line_bbox[0]
|
|
elif text_align == "center":
|
|
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
|
|
elif text_align == "right":
|
|
x = width - padding_x - line_width - line_bbox[0]
|
|
else:
|
|
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
|
|
|
|
if letter_spacing != 0.0 and len(line) > 0:
|
|
pen_x = x
|
|
for char in line:
|
|
draw.text((pen_x, current_y), char, font=font, fill=(255, 255, 255, 255))
|
|
char_width = draw.textlength(char, font=font)
|
|
pen_x += char_width + letter_spacing
|
|
else:
|
|
draw.text((x, current_y), line, font=font, fill=(255, 255, 255, 255))
|
|
|
|
current_y += line_height * line_spacing
|
|
|
|
return text_layer
|
|
|
|
@staticmethod
|
|
def render_outline_mask_pillow(font_path: str, font_size: int, text: str,
|
|
outline_thickness: int, container_size: Tuple[int, int],
|
|
text_align: str = "left",
|
|
padding_x: int = 20, padding_y: int = 25,
|
|
line_spacing: float = 1.0,
|
|
letter_spacing: float = 0.0) -> Image.Image:
|
|
if outline_thickness <= 0:
|
|
return Image.new("RGBA", container_size, (0, 0, 0, 0))
|
|
|
|
width, height = container_size
|
|
text_layer = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(text_layer)
|
|
|
|
try:
|
|
font = ImageFont.truetype(font_path, font_size, layout_engine=ImageFont.Layout.BASIC)
|
|
except:
|
|
font = ImageFont.load_default()
|
|
|
|
lines = text.split('\n')
|
|
if not lines:
|
|
return text_layer
|
|
|
|
first_bbox = draw.textbbox((0, 0), lines[0], font=font)
|
|
line_height = first_bbox[3] - first_bbox[1]
|
|
if line_height == 0:
|
|
line_height = font_size
|
|
|
|
total_text_height = line_height * len(lines) + (len(lines) - 1) * line_height * (line_spacing - 1)
|
|
available_height = height - 2 * padding_y
|
|
y_start = padding_y + (available_height - total_text_height) / 2
|
|
baseline_offset = -first_bbox[1]
|
|
y_start += baseline_offset
|
|
|
|
current_y = y_start
|
|
|
|
for line in lines:
|
|
if not line:
|
|
current_y += line_height * line_spacing
|
|
continue
|
|
|
|
line_bbox = draw.textbbox((0, 0), line, font=font)
|
|
line_width = line_bbox[2] - line_bbox[0]
|
|
if letter_spacing != 0.0 and len(line) > 1:
|
|
line_width += letter_spacing * (len(line) - 1)
|
|
|
|
available_width = width - 2 * padding_x
|
|
if text_align == "left":
|
|
x = padding_x - line_bbox[0]
|
|
elif text_align == "center":
|
|
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
|
|
elif text_align == "right":
|
|
x = width - padding_x - line_width - line_bbox[0]
|
|
else:
|
|
x = padding_x + (available_width - line_width) / 2 - line_bbox[0]
|
|
|
|
if letter_spacing != 0.0 and len(line) > 0:
|
|
pen_x = x
|
|
for char in line:
|
|
draw.text((pen_x, current_y), char, font=font,
|
|
fill=(255, 255, 255, 255),
|
|
stroke_width=outline_thickness,
|
|
stroke_fill=(255, 255, 255, 255))
|
|
char_width = draw.textlength(char, font=font)
|
|
pen_x += char_width + letter_spacing
|
|
else:
|
|
draw.text((x, current_y), line, font=font,
|
|
fill=(255, 255, 255, 255),
|
|
stroke_width=outline_thickness,
|
|
stroke_fill=(255, 255, 255, 255))
|
|
|
|
current_y += line_height * line_spacing
|
|
|
|
return text_layer
|
|
|
|
@staticmethod
|
|
def apply_cairo_transform(image: Image.Image, scale_x: float, scale_y: float) -> Image.Image:
|
|
if abs(scale_x - 1.0) < 0.01 and abs(scale_y - 1.0) < 0.01:
|
|
return image
|
|
|
|
width, height = image.size
|
|
result_width = max(1, int(width * abs(scale_x)))
|
|
result_height = max(1, int(height * abs(scale_y)))
|
|
|
|
arr = np.array(image)
|
|
bgra = np.zeros_like(arr)
|
|
bgra[:, :, 0] = arr[:, :, 2]
|
|
bgra[:, :, 1] = arr[:, :, 1]
|
|
bgra[:, :, 2] = arr[:, :, 0]
|
|
bgra[:, :, 3] = arr[:, :, 3]
|
|
|
|
data = bgra.tobytes()
|
|
source_surface = cairo.ImageSurface.create_for_data(
|
|
bytearray(data), cairo.FORMAT_ARGB32, width, height
|
|
)
|
|
|
|
result_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, result_width, result_height)
|
|
ctx = cairo.Context(result_surface)
|
|
ctx.set_source_rgba(0, 0, 0, 0)
|
|
ctx.paint()
|
|
|
|
ctx.save()
|
|
ctx.translate(result_width / 2, result_height / 2)
|
|
ctx.scale(scale_x, scale_y)
|
|
ctx.translate(-width / 2, -height / 2)
|
|
ctx.set_source_surface(source_surface, 0, 0)
|
|
ctx.paint()
|
|
ctx.restore()
|
|
|
|
buf = result_surface.get_data()
|
|
arr = np.frombuffer(buf, dtype=np.uint8).reshape(result_height, result_width, 4)
|
|
rgba = np.zeros_like(arr)
|
|
rgba[:, :, 0] = arr[:, :, 2]
|
|
rgba[:, :, 1] = arr[:, :, 1]
|
|
rgba[:, :, 2] = arr[:, :, 0]
|
|
rgba[:, :, 3] = arr[:, :, 3]
|
|
|
|
return Image.fromarray(rgba, 'RGBA')
|
|
|
|
class RS_OverlayPro:
|
|
WEB_DIRECTORY = "web"
|
|
_font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
|
|
_freetype_cache: Dict[Tuple[str, int], freetype.Face] = {}
|
|
_text_dim_cache: LRUCache = LRUCache(max_size=200)
|
|
_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):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID"
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "composite"
|
|
CATEGORY = "🦊 RaykoStudio"
|
|
OUTPUT_NODE = True
|
|
DESCRIPTION = "Interactive node for overlaying text on images with real-time positioning, scaling, rotation, and text editing"
|
|
|
|
def __init__(self):
|
|
self.hybrid_renderer = HybridTextRenderer()
|
|
|
|
def get_font_path(self, font_name: str) -> Optional[str]:
|
|
return safe_font_path(font_name)
|
|
|
|
@functools.lru_cache(maxsize=64)
|
|
def get_cached_font(self, font_path: str, size: int) -> ImageFont.ImageFont:
|
|
cache_key = (font_path, size)
|
|
if cache_key in self._font_cache:
|
|
return self._font_cache[cache_key]
|
|
|
|
try:
|
|
font = ImageFont.truetype(font_path, size, layout_engine=ImageFont.Layout.BASIC)
|
|
font.path = font_path
|
|
font.size = size
|
|
self._font_cache[cache_key] = font
|
|
return font
|
|
except Exception as e:
|
|
raise Exception(f"Font error: {e}")
|
|
|
|
@functools.lru_cache(maxsize=64)
|
|
def get_freetype_face(self, font_path: str, size: int) -> freetype.Face:
|
|
cache_key = (font_path, size)
|
|
if cache_key in self._freetype_cache:
|
|
return self._freetype_cache[cache_key]
|
|
|
|
try:
|
|
face = freetype.Face(font_path)
|
|
face.set_char_size(size * 64)
|
|
self._freetype_cache[cache_key] = face
|
|
return face
|
|
except Exception as e:
|
|
raise Exception(f"FreeType error: {e}")
|
|
|
|
def parse_color(self, color: Any, alpha: float = 1.0) -> Tuple[int, int, int, int]:
|
|
if color is None:
|
|
return (255, 255, 255, int(alpha * 255))
|
|
|
|
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)
|
|
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)
|
|
return (r, g, b, a)
|
|
else:
|
|
return (255, 255, 255, int(alpha * 255))
|
|
else:
|
|
return (255, 255, 255, int(alpha * 255))
|
|
except Exception:
|
|
return (255, 255, 255, int(alpha * 255))
|
|
|
|
def _extract_text_params(self, text_params: dict, container_size: Tuple[int, int]) -> dict:
|
|
font_list = self._get_font_list()
|
|
text = text_params.get('text', '')
|
|
if not text or not text.strip():
|
|
text = ''
|
|
|
|
font_name = text_params.get('font_name', '')
|
|
if not font_name or font_name not in font_list:
|
|
font_name = font_list[0] if font_list else ''
|
|
|
|
font_path = self.get_font_path(font_name)
|
|
if font_path is None and font_list:
|
|
font_path = self.get_font_path(font_list[0])
|
|
|
|
text_color = text_params.get('text_color', '#FFFFFF')
|
|
outline_color = text_params.get('outline_color', '#808080')
|
|
outline_thickness = int(text_params.get('outline_thickness', 0))
|
|
effect_mode = str(text_params.get('effect_mode', 'on')).lower()
|
|
effect_mode = 'on' if effect_mode in ('on', 'off') else 'on'
|
|
text_scale_x = float(text_params.get('text_scale_x', 1.0))
|
|
text_scale_y = float(text_params.get('text_scale_y', 1.0))
|
|
text_align = str(text_params.get('text_align', 'left')).lower()
|
|
text_align = text_align if text_align in ('left', 'center', 'right') else 'left'
|
|
line_spacing = max(0.5, min(3.0, float(text_params.get('line_spacing', 1.0))))
|
|
letter_spacing = max(-20.0, min(100.0, float(text_params.get('letter_spacing', 0.0))))
|
|
min_font_size = int(text_params.get('min_font_size', 4))
|
|
max_font_size = int(text_params.get('max_font_size', 2000))
|
|
text_opacity = min(1.0, max(0.0, float(text_params.get('text_opacity', 1.0))))
|
|
outline_opacity = min(1.0, max(0.0, float(text_params.get('outline_opacity', 1.0))))
|
|
enable_glow = bool(text_params.get('enable_glow', False))
|
|
glow_color = text_params.get('glow_color', '#FFFFFF')
|
|
glow_size = int(text_params.get('glow_size', 0))
|
|
glow_spread = int(text_params.get('glow_spread', 0))
|
|
glow_opacity = min(1.0, max(0.0, float(text_params.get('glow_opacity', 1.0))))
|
|
|
|
padding_x = int(container_size[0] * 0.2) # 20% ширины
|
|
padding_y = int(container_size[1] * 0.15) # 15% высоты
|
|
|
|
return {
|
|
'text': text,
|
|
'font_name': font_name,
|
|
'font_path': font_path,
|
|
'text_color': text_color,
|
|
'outline_color': outline_color,
|
|
'outline_thickness': outline_thickness,
|
|
'effect_mode': effect_mode,
|
|
'text_scale_x': text_scale_x,
|
|
'text_scale_y': text_scale_y,
|
|
'text_align': text_align,
|
|
'line_spacing': line_spacing,
|
|
'letter_spacing': letter_spacing,
|
|
'min_font_size': min_font_size,
|
|
'max_font_size': max_font_size,
|
|
'text_opacity': text_opacity,
|
|
'outline_opacity': outline_opacity,
|
|
'enable_glow': enable_glow,
|
|
'glow_color': glow_color,
|
|
'glow_size': glow_size,
|
|
'glow_spread': glow_spread,
|
|
'glow_opacity': glow_opacity,
|
|
'padding_x': padding_x,
|
|
'padding_y': padding_y,
|
|
}
|
|
|
|
def calculate_text_dimensions_freetype(self, font_path: str, size: int, text: str,
|
|
outline_thickness: int, line_spacing: float = 1.0,
|
|
letter_spacing: float = 0.0) -> Tuple[float, float, List[float], List[float]]:
|
|
cache_key = (f"{font_path}:{size}", text, outline_thickness, letter_spacing, line_spacing)
|
|
cached = self._text_dim_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
face = self.get_freetype_face(font_path, size)
|
|
except:
|
|
return self.calculate_text_dimensions_legacy(f"{font_path}:{size}", text, outline_thickness, line_spacing, letter_spacing)
|
|
|
|
ascender = face.size.ascender / 64
|
|
descender = face.size.descender / 64
|
|
line_height_fixed = ascender - descender
|
|
|
|
lines = text.split('\n')
|
|
line_heights = []
|
|
line_widths = []
|
|
|
|
for line in lines:
|
|
line = line.replace('\t', ' ')
|
|
if not line:
|
|
line_heights.append(line_height_fixed + outline_thickness * 2)
|
|
line_widths.append(0 + outline_thickness * 2)
|
|
continue
|
|
|
|
ink_left = float('inf')
|
|
ink_right = float('-inf')
|
|
pen_x = 0
|
|
prev_glyph_index = 0
|
|
|
|
for i, char in enumerate(line):
|
|
try:
|
|
face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP)
|
|
glyph = face.glyph
|
|
metrics = glyph.metrics
|
|
|
|
if i > 0 and prev_glyph_index != 0:
|
|
kerning = face.get_kerning(prev_glyph_index, glyph.index)
|
|
pen_x += kerning.x / 64
|
|
|
|
glyph_left = pen_x + metrics.horiBearingX / 64
|
|
glyph_right = glyph_left + metrics.width / 64
|
|
|
|
ink_left = min(ink_left, glyph_left)
|
|
ink_right = max(ink_right, glyph_right)
|
|
|
|
pen_x += metrics.horiAdvance / 64
|
|
if letter_spacing != 0.0:
|
|
pen_x += letter_spacing
|
|
prev_glyph_index = glyph.index
|
|
except Exception:
|
|
continue
|
|
|
|
if ink_left == float('inf'):
|
|
line_width = 0
|
|
else:
|
|
line_width = ink_right - ink_left
|
|
|
|
line_heights.append(line_height_fixed + outline_thickness * 2)
|
|
line_widths.append(line_width + outline_thickness * 2)
|
|
|
|
if line_heights:
|
|
total_height = sum(line_heights)
|
|
if len(line_heights) > 1:
|
|
avg_line_height = line_heights[0]
|
|
total_height += (len(line_heights) - 1) * avg_line_height * (line_spacing - 1)
|
|
else:
|
|
total_height = 0
|
|
max_width = max(line_widths) if line_widths else 0
|
|
|
|
result = (total_height, max_width, line_heights, line_widths)
|
|
self._text_dim_cache.put(cache_key, result)
|
|
return result
|
|
|
|
def calculate_text_dimensions_legacy(self, font_key: str, text: str, outline_thickness: int,
|
|
line_spacing: float = 1.0, letter_spacing: float = 0.0) -> Tuple[float, float, list, list]:
|
|
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')
|
|
line_heights, line_widths = [], []
|
|
|
|
for line in lines:
|
|
line = line.replace('\t', ' ')
|
|
if not line:
|
|
line_heights.append(0)
|
|
line_widths.append(0)
|
|
continue
|
|
|
|
bbox = draw.textbbox((0, 0), line, font=font)
|
|
line_width = draw.textlength(line, font=font)
|
|
|
|
if letter_spacing != 0.0 and len(line) > 1:
|
|
line_width += letter_spacing * (len(line) - 1)
|
|
|
|
line_height = (bbox[3] - bbox[1])
|
|
line_heights.append(line_height + outline_thickness * 2)
|
|
line_widths.append(line_width + outline_thickness * 2)
|
|
|
|
if line_heights:
|
|
total_height = sum(line_heights)
|
|
if len(line_heights) > 1:
|
|
avg_h = line_heights[0]
|
|
total_height += (len(line_heights) - 1) * avg_h * (line_spacing - 1)
|
|
else:
|
|
total_height = 0
|
|
max_width = max(line_widths) if line_widths else 0
|
|
return (total_height, max_width, line_heights, line_widths)
|
|
|
|
def text_fits(self, font_path: str, size: int, text: str, max_width: float, max_height: float,
|
|
outline_thickness: int, line_spacing: float = 1.0, letter_spacing: float = 0.0) -> bool:
|
|
total_height, max_line_width, _, _ = self.calculate_text_dimensions_freetype(
|
|
font_path, size, text, outline_thickness, line_spacing, letter_spacing
|
|
)
|
|
return max_line_width <= max_width and total_height <= max_height
|
|
|
|
def find_optimal_font_size(self, font_path: Optional[str], text: str,
|
|
max_width: float, max_height: float,
|
|
min_size: int, max_size: int, config: TextRenderConfig) -> Tuple[ImageFont.ImageFont, int]:
|
|
if font_path is None:
|
|
font_path = "default"
|
|
|
|
if self.text_fits(font_path, max_size, text, max_width, max_height,
|
|
config.outline_thickness,
|
|
config.line_spacing, config.letter_spacing):
|
|
try:
|
|
font = self.get_cached_font(font_path, max_size) if font_path != "default" else ImageFont.load_default()
|
|
if font_path != "default" 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
|
|
iterations = 0
|
|
max_iterations = 50
|
|
|
|
while low <= high and iterations < max_iterations:
|
|
mid = (low + high) // 2
|
|
iterations += 1
|
|
|
|
try:
|
|
font = self.get_cached_font(font_path, mid) if font_path != "default" else ImageFont.load_default()
|
|
except:
|
|
high = mid - 1
|
|
continue
|
|
|
|
if self.text_fits(font_path, mid, text, max_width, max_height,
|
|
config.outline_thickness,
|
|
config.line_spacing, config.letter_spacing):
|
|
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 != "default" else ImageFont.load_default()
|
|
if font_path != "default" 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 _get_font_list(self) -> List[str]:
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
fonts_dir = os.path.join(current_dir, "fonts")
|
|
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)
|
|
|
|
font_list = [f for f in font_list if os.path.basename(f) == f]
|
|
|
|
if "Arial.ttf" in font_list:
|
|
font_list.remove("Arial.ttf")
|
|
font_list.insert(0, "Arial.ttf")
|
|
|
|
if not font_list:
|
|
font_list = ["Arial.ttf"]
|
|
|
|
return font_list
|
|
|
|
def render_masks_from_params(self, text_params: dict, container_size: Tuple[int, int]) -> Tuple[Dict[str, Image.Image], Tuple[int, int]]:
|
|
p = self._extract_text_params(text_params, container_size)
|
|
text = p['text']
|
|
if not text:
|
|
empty_mask = Image.new("RGBA", container_size, (0,0,0,0))
|
|
return {'text_mask': empty_mask, 'outline_mask': empty_mask}, container_size
|
|
|
|
font_path = p['font_path']
|
|
if font_path is None:
|
|
font_list = self._get_font_list()
|
|
font_path = self.get_font_path(font_list[0])
|
|
|
|
temp_config = TextRenderConfig(
|
|
font=None,
|
|
font_path=font_path,
|
|
font_size=0,
|
|
text_color=(255,255,255,255),
|
|
outline_color=(255,255,255,255),
|
|
outline_thickness=p['outline_thickness'],
|
|
effect_mode=p['effect_mode'],
|
|
text_scale_x=p['text_scale_x'],
|
|
text_scale_y=p['text_scale_y'],
|
|
text_align=p['text_align'],
|
|
line_spacing=p['line_spacing'],
|
|
letter_spacing=p['letter_spacing']
|
|
)
|
|
|
|
available_w = max(1, container_size[0] - 2 * p['padding_x'])
|
|
available_h = max(1, container_size[1] - 2 * p['padding_y'])
|
|
|
|
font, actual_size = self.find_optimal_font_size(
|
|
font_path, text, available_w, available_h,
|
|
p['min_font_size'], p['max_font_size'], temp_config
|
|
)
|
|
|
|
text_mask = self.hybrid_renderer.render_text_mask_pillow(
|
|
font_path, actual_size, text, container_size, p['text_align'],
|
|
p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing']
|
|
)
|
|
text_mask = self.hybrid_renderer.apply_cairo_transform(
|
|
text_mask, p['text_scale_x'], p['text_scale_y']
|
|
)
|
|
|
|
masks = {'text_mask': text_mask}
|
|
|
|
if p['effect_mode'] == "on" and p['outline_thickness'] > 0:
|
|
outline_mask = self.hybrid_renderer.render_outline_mask_pillow(
|
|
font_path, actual_size, text, p['outline_thickness'], container_size,
|
|
p['text_align'], p['padding_x'], p['padding_y'],
|
|
p['line_spacing'], p['letter_spacing']
|
|
)
|
|
text_mask_no_transform = self.hybrid_renderer.render_text_mask_pillow(
|
|
font_path, actual_size, text, container_size, p['text_align'],
|
|
p['padding_x'], p['padding_y'], p['line_spacing'], p['letter_spacing']
|
|
)
|
|
text_arr = np.array(text_mask_no_transform)
|
|
outline_arr = np.array(outline_mask)
|
|
text_alpha = text_arr[:, :, 3].astype(np.float32)
|
|
outline_alpha = outline_arr[:, :, 3].astype(np.float32)
|
|
outline_only_alpha = np.clip(outline_alpha - text_alpha, 0, 255).astype(np.uint8)
|
|
|
|
outline_only_arr = np.zeros_like(outline_arr)
|
|
outline_only_arr[:, :, 0] = 255
|
|
outline_only_arr[:, :, 1] = 255
|
|
outline_only_arr[:, :, 2] = 255
|
|
outline_only_arr[:, :, 3] = outline_only_alpha
|
|
|
|
outline_only_mask = Image.fromarray(outline_only_arr, 'RGBA')
|
|
outline_only_mask = self.hybrid_renderer.apply_cairo_transform(
|
|
outline_only_mask, p['text_scale_x'], p['text_scale_y']
|
|
)
|
|
masks['outline_mask'] = outline_only_mask
|
|
else:
|
|
masks['outline_mask'] = Image.new("RGBA", (text_mask.width, text_mask.height), (0,0,0,0))
|
|
|
|
render_size = (text_mask.width, text_mask.height)
|
|
return masks, render_size
|
|
|
|
def render_text_from_params(self, text_params: dict, container_size: Tuple[int, int], include_glow: bool = False) -> Image.Image:
|
|
p = self._extract_text_params(text_params, container_size)
|
|
text = p['text']
|
|
if not text:
|
|
return Image.new("RGBA", container_size, (0,0,0,0))
|
|
|
|
masks, render_size = self.render_masks_from_params(text_params, container_size)
|
|
text_mask = masks['text_mask']
|
|
outline_mask = masks['outline_mask']
|
|
|
|
enable_shadow = bool(text_params.get('enable_shadow', False))
|
|
shadow_color = text_params.get('shadow_color', '#333333')
|
|
shadow_opacity = min(1.0, max(0.0, float(text_params.get('shadow_opacity', 0.8))))
|
|
shadow_offset_x = int(text_params.get('shadow_offset_x', 0))
|
|
shadow_offset_y = int(text_params.get('shadow_offset_y', 0))
|
|
shadow_blur = int(text_params.get('shadow_blur', 0))
|
|
|
|
config = TextRenderConfig(
|
|
font=None,
|
|
font_path=p['font_path'],
|
|
font_size=0,
|
|
text_color=self.parse_color(p['text_color'], p['text_opacity']),
|
|
outline_color=self.parse_color(p['outline_color'], p['outline_opacity']),
|
|
outline_thickness=p['outline_thickness'],
|
|
effect_mode=p['effect_mode'],
|
|
text_scale_x=p['text_scale_x'],
|
|
text_scale_y=p['text_scale_y'],
|
|
text_align=p['text_align'],
|
|
line_spacing=p['line_spacing'],
|
|
letter_spacing=p['letter_spacing'],
|
|
enable_glow=p['enable_glow'],
|
|
glow_color=self.parse_color(p['glow_color'], p['glow_opacity']),
|
|
glow_size=p['glow_size'],
|
|
glow_spread=p['glow_spread'],
|
|
glow_opacity=p['glow_opacity'],
|
|
enable_shadow=enable_shadow,
|
|
shadow_color=self.parse_color(shadow_color, shadow_opacity),
|
|
shadow_offset_x=shadow_offset_x,
|
|
shadow_offset_y=shadow_offset_y,
|
|
shadow_opacity=shadow_opacity,
|
|
shadow_blur=shadow_blur
|
|
)
|
|
|
|
result = Image.new("RGBA", render_size, (0,0,0,0))
|
|
|
|
if include_glow and config.enable_glow and config.glow_size > 0:
|
|
glow_mask = self._apply_glow_to_mask(
|
|
text_mask, config.glow_size, config.glow_spread, config.glow_opacity
|
|
)
|
|
glow_colored = self._apply_color_to_mask(glow_mask, config.glow_color)
|
|
result = Image.alpha_composite(result, glow_colored)
|
|
|
|
if config.enable_shadow:
|
|
shadow_mask = self._apply_shadow_to_mask(
|
|
text_mask, config.shadow_blur, config.shadow_offset_x, config.shadow_offset_y
|
|
)
|
|
shadow_colored = self._apply_color_to_mask(shadow_mask, config.shadow_color)
|
|
result = Image.alpha_composite(result, shadow_colored)
|
|
|
|
if config.has_outline:
|
|
outline_colored = self._apply_color_to_mask(outline_mask, config.outline_color)
|
|
result = Image.alpha_composite(result, outline_colored)
|
|
|
|
text_colored = self._apply_color_to_mask(text_mask, config.text_color)
|
|
result = Image.alpha_composite(result, text_colored)
|
|
|
|
return result
|
|
|
|
def _apply_glow_to_mask(self, text_mask: Image.Image, glow_size: int,
|
|
glow_spread: int, glow_opacity: float) -> Image.Image:
|
|
if glow_size <= 0:
|
|
return Image.new("RGBA", text_mask.size, (0, 0, 0, 0))
|
|
|
|
w, h = text_mask.size
|
|
alpha = np.array(text_mask.getchannel("A"), dtype=np.uint8)
|
|
|
|
_, binary = cv2.threshold(alpha, 127, 255, cv2.THRESH_BINARY)
|
|
original_mask = binary.copy()
|
|
|
|
if glow_spread > 0:
|
|
iterations = max(1, int(glow_spread / 15))
|
|
kernel = np.ones((3, 3), np.uint8)
|
|
binary = cv2.dilate(binary, kernel, iterations=iterations)
|
|
|
|
sigma = max(0.5, glow_size / 2.0)
|
|
blurred = cv2.GaussianBlur(binary, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
|
|
|
glow_float = blurred.astype(np.float32) / 255.0
|
|
original_float = original_mask.astype(np.float32) / 255.0
|
|
glow_outer = glow_float * (1.0 - original_float)
|
|
glow_outer = glow_outer * glow_opacity
|
|
|
|
glow_arr = np.clip(glow_outer * 255, 0, 255).astype(np.uint8)
|
|
glow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
|
|
glow_rgba[:, :, 3] = glow_arr
|
|
|
|
return Image.fromarray(glow_rgba, 'RGBA')
|
|
|
|
def _apply_color_to_mask(self, mask: Image.Image, color: Tuple[int, int, int, int]) -> Image.Image:
|
|
arr = np.array(mask)
|
|
alpha = arr[:, :, 3]
|
|
|
|
colored_arr = np.zeros_like(arr)
|
|
colored_arr[:, :, 0] = color[0]
|
|
colored_arr[:, :, 1] = color[1]
|
|
colored_arr[:, :, 2] = color[2]
|
|
colored_arr[:, :, 3] = (alpha.astype(np.float32) * (color[3] / 255.0)).astype(np.uint8)
|
|
|
|
return Image.fromarray(colored_arr, 'RGBA')
|
|
|
|
def _apply_shadow_to_mask(self, text_mask: Image.Image, shadow_blur: int,
|
|
shadow_offset_x: int, shadow_offset_y: int) -> Image.Image:
|
|
from scipy import ndimage
|
|
|
|
w, h = text_mask.size
|
|
|
|
alpha = np.array(text_mask.getchannel("A"), dtype=np.float32)
|
|
|
|
if shadow_blur > 0:
|
|
alpha = ndimage.gaussian_filter(alpha, sigma=shadow_blur / 2.0)
|
|
|
|
shadow_alpha = np.zeros((h, w), dtype=np.float32)
|
|
|
|
if shadow_offset_x >= 0:
|
|
src_x_start, src_x_end = 0, w - shadow_offset_x
|
|
dst_x_start, dst_x_end = shadow_offset_x, w
|
|
else:
|
|
src_x_start, src_x_end = -shadow_offset_x, w
|
|
dst_x_start, dst_x_end = 0, w + shadow_offset_x
|
|
|
|
if shadow_offset_y >= 0:
|
|
src_y_start, src_y_end = 0, h - shadow_offset_y
|
|
dst_y_start, dst_y_end = shadow_offset_y, h
|
|
else:
|
|
src_y_start, src_y_end = -shadow_offset_y, h
|
|
dst_y_start, dst_y_end = 0, h + shadow_offset_y
|
|
|
|
src_x_start = max(0, src_x_start)
|
|
src_x_end = max(0, min(w, src_x_end))
|
|
src_y_start = max(0, src_y_start)
|
|
src_y_end = max(0, min(h, src_y_end))
|
|
dst_x_start = max(0, dst_x_start)
|
|
dst_x_end = max(0, min(w, dst_x_end))
|
|
dst_y_start = max(0, dst_y_start)
|
|
dst_y_end = max(0, min(h, dst_y_end))
|
|
|
|
copy_w = min(src_x_end - src_x_start, dst_x_end - dst_x_start)
|
|
copy_h = min(src_y_end - src_y_start, dst_y_end - dst_y_start)
|
|
|
|
if copy_w > 0 and copy_h > 0:
|
|
shadow_alpha[dst_y_start:dst_y_start+copy_h, dst_x_start:dst_x_start+copy_w] = \
|
|
alpha[src_y_start:src_y_start+copy_h, src_x_start:src_x_start+copy_w]
|
|
|
|
shadow_rgba = np.zeros((h, w, 4), dtype=np.uint8)
|
|
shadow_rgba[:, :, 3] = np.clip(shadow_alpha, 0, 255).astype(np.uint8)
|
|
|
|
return Image.fromarray(shadow_rgba, 'RGBA')
|
|
|
|
def _calculate_extra_padding(self, text_params: dict) -> int:
|
|
extra = 0
|
|
if text_params.get('enable_glow', False):
|
|
glow_size = int(text_params.get('glow_size', 0))
|
|
glow_spread = int(text_params.get('glow_spread', 0))
|
|
if glow_size > 0:
|
|
extra = max(extra, int(glow_size * 1.0 + glow_spread * 0.3) + 10)
|
|
if text_params.get('enable_shadow', False):
|
|
shadow_offset_x = abs(int(text_params.get('shadow_offset_x', 0)))
|
|
shadow_offset_y = abs(int(text_params.get('shadow_offset_y', 0)))
|
|
shadow_extra = max(shadow_offset_x, shadow_offset_y) + 20
|
|
extra = max(extra, shadow_extra)
|
|
extra = min(extra, 300)
|
|
return extra
|
|
|
|
def tensor2pil(self, tensor):
|
|
if len(tensor.shape) == 4:
|
|
if tensor.shape[0] > 1:
|
|
tensor = tensor[0]
|
|
arr = (tensor.cpu().numpy() * 255).astype(np.uint8)
|
|
while arr.ndim > 3:
|
|
arr = arr[0]
|
|
if arr.ndim < 3:
|
|
arr = np.expand_dims(arr, axis=-1)
|
|
c = arr.shape[2]
|
|
if c >= 4:
|
|
return Image.fromarray(arr[:,:,:4], 'RGBA')
|
|
if c == 3:
|
|
return Image.fromarray(arr, 'RGB')
|
|
return Image.fromarray(arr[:,:,0], 'L')
|
|
|
|
def pil2tensor(self, pil):
|
|
if pil.mode == 'L':
|
|
pil = pil.convert('RGB')
|
|
elif pil.mode != 'RGB':
|
|
pass
|
|
return torch.from_numpy(np.array(pil).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
def composite(self, image, unique_id=None):
|
|
unique_id = str(unique_id) if unique_id is not None else "unknown"
|
|
|
|
if image.numel() == 0:
|
|
return (image,)
|
|
|
|
h, w = image.shape[1], image.shape[2]
|
|
if h < 8 or w < 8:
|
|
return (image,)
|
|
|
|
TRANSFORM_CACHE.cache.pop(unique_id, None)
|
|
|
|
bg_pil = self.tensor2pil(image)
|
|
bg_w, bg_h = bg_pil.size
|
|
|
|
font_list = self._get_font_list()
|
|
|
|
ts = int(time.time() * 1000)
|
|
td = folder_paths.get_temp_directory()
|
|
|
|
for f in os.listdir(td):
|
|
if f.startswith(f"rs_overlay_pro_{unique_id}_"):
|
|
try:
|
|
os.remove(os.path.join(td, f))
|
|
except:
|
|
pass
|
|
|
|
bfn = f"rs_overlay_pro_{unique_id}_{ts}_bg.png"
|
|
bg_pil.save(os.path.join(td, bfn))
|
|
|
|
PENDING_TRANSFORMS.cache[unique_id] = {"status": "pending"}
|
|
|
|
PromptServer.instance.send_sync("rs-overlay-pro-start", {
|
|
"id": unique_id,
|
|
"bg_file": bfn,
|
|
"bg_width": bg_w,
|
|
"bg_height": bg_h,
|
|
"timestamp": ts,
|
|
"font_list": font_list,
|
|
"default_font": font_list[0]
|
|
})
|
|
|
|
print(f"[RS Overlay Pro] Node {unique_id} waiting for user input (Apply/Cancel)...")
|
|
|
|
timeout = 300
|
|
start_time = time.time()
|
|
while unique_id not in TRANSFORM_CACHE.cache:
|
|
if unique_id in PENDING_TRANSFORMS.cache and PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled":
|
|
break
|
|
if time.time() - start_time > timeout:
|
|
print(f"[RS Overlay Pro] Timeout for node {unique_id}")
|
|
PENDING_TRANSFORMS.cache.pop(unique_id, None)
|
|
try:
|
|
os.remove(os.path.join(td, bfn))
|
|
except:
|
|
pass
|
|
return (self.pil2tensor(bg_pil),)
|
|
time.sleep(0.2)
|
|
|
|
if unique_id in PENDING_TRANSFORMS.cache:
|
|
if PENDING_TRANSFORMS.cache[unique_id].get("status") == "cancelled":
|
|
print(f"[RS Overlay Pro] Cancelled by user for node {unique_id}")
|
|
PENDING_TRANSFORMS.cache.pop(unique_id, None)
|
|
try:
|
|
os.remove(os.path.join(td, bfn))
|
|
except:
|
|
pass
|
|
return (self.pil2tensor(bg_pil),)
|
|
|
|
print(f"[RS Overlay Pro] Input received for node {unique_id}")
|
|
PENDING_TRANSFORMS.cache.pop(unique_id, None)
|
|
|
|
data = TRANSFORM_CACHE.cache.pop(unique_id, None)
|
|
if data is None:
|
|
return (self.pil2tensor(bg_pil),)
|
|
|
|
text_params = data.get("text_params", {})
|
|
|
|
text = text_params.get('text', '')
|
|
if not text or not text.strip():
|
|
try:
|
|
os.remove(os.path.join(td, bfn))
|
|
except:
|
|
pass
|
|
return (self.pil2tensor(bg_pil),)
|
|
|
|
rx = bg_w / 2 if data.get("x") is None else float(data.get("x"))
|
|
ry = bg_h / 2 if data.get("y") is None else float(data.get("y"))
|
|
rot = float(data.get("rotation") or 0)
|
|
|
|
base_width_px = float(text_params.get('base_width_px', bg_w))
|
|
base_height_px = float(text_params.get('base_height_px', bg_w * 3 / 5))
|
|
base_width_px = max(10, min(bg_w, base_width_px))
|
|
base_height_px = max(10, min(bg_h, base_height_px))
|
|
|
|
text_layer = self.render_text_from_params(
|
|
text_params, (int(base_width_px), int(base_height_px)),
|
|
include_glow=True
|
|
)
|
|
|
|
if rot != 0:
|
|
text_layer = text_layer.rotate(-rot, expand=True, resample=Image.Resampling.BICUBIC,
|
|
center=(text_layer.width//2, text_layer.height//2))
|
|
|
|
result = bg_pil.copy().convert("RGBA")
|
|
paste_x = int(rx - text_layer.width // 2)
|
|
paste_y = int(ry - text_layer.height // 2)
|
|
result.paste(text_layer, (paste_x, paste_y), text_layer if text_layer.mode == 'RGBA' else None)
|
|
|
|
try:
|
|
os.remove(os.path.join(td, bfn))
|
|
except:
|
|
pass
|
|
|
|
return (self.pil2tensor(result.convert("RGB")),)
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
return time.time()
|
|
|
|
NODE_CLASS_MAPPINGS = {"RS_OverlayPro": RS_OverlayPro}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {"RS_OverlayPro": "🦊 RS Text Overlay Pro"} |