587 lines
21 KiB
Python
587 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import uuid
|
|
from typing import Any, Tuple
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
|
|
|
|
NODE_ID = "Orion4D_LayerManager"
|
|
DISPLAY_NAME = "🧩 Layer Manager"
|
|
CATEGORY = "Orion4D_Layer"
|
|
MAX_LAYERS = 20
|
|
|
|
|
|
def _first_image(img_tensor: torch.Tensor | None) -> torch.Tensor | None:
|
|
if img_tensor is None:
|
|
return None
|
|
t = img_tensor.detach()
|
|
if t.dim() == 4:
|
|
t = t[0]
|
|
if t.dim() != 3:
|
|
return None
|
|
return t
|
|
|
|
|
|
def tensor_image_to_pil_rgba(img_tensor: torch.Tensor | None) -> Image.Image | None:
|
|
t = _first_image(img_tensor)
|
|
if t is None:
|
|
return None
|
|
arr = (t.clamp(0, 1).cpu().numpy() * 255.0).round().astype(np.uint8)
|
|
if arr.ndim != 3:
|
|
return None
|
|
|
|
c = arr.shape[-1]
|
|
if c == 1:
|
|
rgb = np.repeat(arr, 3, axis=2)
|
|
alpha = np.full((*arr.shape[:2], 1), 255, dtype=np.uint8)
|
|
arr = np.concatenate([rgb, alpha], axis=2)
|
|
elif c == 2:
|
|
gray = np.repeat(arr[:, :, :1], 3, axis=2)
|
|
alpha = arr[:, :, 1:2]
|
|
arr = np.concatenate([gray, alpha], axis=2)
|
|
elif c == 3:
|
|
alpha = np.full((*arr.shape[:2], 1), 255, dtype=np.uint8)
|
|
arr = np.concatenate([arr, alpha], axis=2)
|
|
else:
|
|
arr = arr[:, :, :4]
|
|
return Image.fromarray(arr, mode="RGBA")
|
|
|
|
|
|
def tensor_mask_to_pil_l(mask_tensor: torch.Tensor | None, width: int, height: int, invert: bool = False) -> Image.Image | None:
|
|
if mask_tensor is None:
|
|
return None
|
|
mt = mask_tensor.detach()
|
|
if mt.dim() == 4:
|
|
mt = mt[0]
|
|
if mt.dim() == 3:
|
|
if mt.shape[-1] == 1:
|
|
mt = mt[:, :, 0]
|
|
else:
|
|
mt = mt[0]
|
|
if mt.dim() != 2:
|
|
return None
|
|
|
|
arr = mt.clamp(0, 1).cpu().numpy().astype(np.float32)
|
|
if invert:
|
|
arr = 1.0 - arr
|
|
arr = (arr * 255.0).round().clip(0, 255).astype(np.uint8)
|
|
pil = Image.fromarray(arr, mode="L")
|
|
if pil.size != (width, height):
|
|
pil = pil.resize((width, height), Image.BICUBIC)
|
|
return pil
|
|
|
|
|
|
def apply_external_mask_to_rgba(layer_rgba: Image.Image, mask_tensor: torch.Tensor | None, invert_mask: bool) -> Image.Image:
|
|
mask = tensor_mask_to_pil_l(mask_tensor, layer_rgba.width, layer_rgba.height, invert=invert_mask)
|
|
if mask is None:
|
|
return layer_rgba
|
|
arr = np.asarray(layer_rgba, dtype=np.uint8).copy()
|
|
mask_arr = np.asarray(mask, dtype=np.float32) / 255.0
|
|
arr[:, :, 3] = (arr[:, :, 3].astype(np.float32) * mask_arr).round().clip(0, 255).astype(np.uint8)
|
|
return Image.fromarray(arr, mode="RGBA")
|
|
|
|
|
|
def pil_rgba_to_tensors(pil_img: Image.Image) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
rgba = np.asarray(pil_img.convert("RGBA"), dtype=np.float32) / 255.0
|
|
rgb = torch.from_numpy(rgba[:, :, :3])[None, ...]
|
|
rgba_t = torch.from_numpy(rgba)[None, ...]
|
|
alpha = torch.from_numpy(rgba[:, :, 3])[None, ...]
|
|
return rgb, rgba_t, alpha
|
|
|
|
|
|
def pil_rgb_tensor(pil_img: Image.Image) -> torch.Tensor:
|
|
arr = np.asarray(pil_img.convert("RGB"), dtype=np.float32) / 255.0
|
|
return torch.from_numpy(arr)[None, ...]
|
|
|
|
|
|
def parse_bg(bg_hex: str) -> tuple[int, int, int, int]:
|
|
if not isinstance(bg_hex, str):
|
|
return 255, 255, 255, 255
|
|
s = bg_hex.strip()
|
|
if s.lower() in {"transparent", "none", "alpha", "rgba"}:
|
|
return 0, 0, 0, 0
|
|
hx = s.lstrip("#")
|
|
if len(hx) == 3:
|
|
hx = "".join(ch * 2 for ch in hx) + "FF"
|
|
elif len(hx) == 4:
|
|
hx = "".join(ch * 2 for ch in hx)
|
|
elif len(hx) == 6:
|
|
hx += "FF"
|
|
if len(hx) != 8:
|
|
return 255, 255, 255, 255
|
|
try:
|
|
return tuple(int(hx[i:i+2], 16) for i in (0, 2, 4, 6)) # type: ignore[return-value]
|
|
except ValueError:
|
|
return 255, 255, 255, 255
|
|
|
|
|
|
def visible_to_bool(value: Any, default: bool = True) -> bool:
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
v = value.strip().lower()
|
|
if v in {"false", "0", "hidden", "hide", "off", "no", "disabled"}:
|
|
return False
|
|
if v in {"true", "1", "visible", "show", "on", "yes", "enabled"}:
|
|
return True
|
|
return default
|
|
return bool(value)
|
|
|
|
|
|
def normalize_anchor(anchor: Any) -> str:
|
|
if not isinstance(anchor, str):
|
|
return "top_left"
|
|
value = anchor.strip().lower()
|
|
mapping = {
|
|
"haut_gauche": "top_left", "top_left": "top_left", "topleft": "top_left", "upper_left": "top_left",
|
|
"haut_droite": "top_right", "top_right": "top_right", "topright": "top_right", "upper_right": "top_right",
|
|
"bas_gauche": "bottom_left", "bottom_left": "bottom_left", "bottomleft": "bottom_left", "lower_left": "bottom_left",
|
|
"bas_droite": "bottom_right", "bottom_right": "bottom_right", "bottomright": "bottom_right", "lower_right": "bottom_right",
|
|
"centre": "center", "center": "center", "middle": "center",
|
|
}
|
|
return mapping.get(value, "top_left")
|
|
|
|
|
|
def normalize_blend_mode(mode: Any) -> str:
|
|
if not isinstance(mode, str):
|
|
return "normal"
|
|
value = mode.strip().lower().replace(" ", "_")
|
|
allowed = {
|
|
"normal", "multiply", "screen", "overlay", "darken", "lighten",
|
|
"add", "subtract", "difference", "soft_light", "hard_light",
|
|
}
|
|
return value if value in allowed else "normal"
|
|
|
|
|
|
def _default_layer() -> dict[str, Any]:
|
|
return {
|
|
"name": "", "x": 0, "y": 0, "scale": 100.0, "scale_x": 100.0, "scale_y": 100.0,
|
|
"constrain_homothety": True, "collapsed": False, "rot": 0.0, "opacity": 1.0,
|
|
"visible": True, "edit": False, "anchor": "top_left", "blend": "normal",
|
|
}
|
|
|
|
|
|
def parse_layer_config(layer_config: Any, kwargs: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
|
data: dict[str, Any] = {}
|
|
if isinstance(layer_config, str) and layer_config.strip():
|
|
try:
|
|
data = json.loads(layer_config)
|
|
except Exception:
|
|
data = {}
|
|
elif isinstance(layer_config, dict):
|
|
data = layer_config
|
|
|
|
out = {}
|
|
layers_data = data.get("layers")
|
|
|
|
# Extraction depuis le JSON (Indexé en base 1 désormais)
|
|
if isinstance(layers_data, list):
|
|
for idx, layer in enumerate(layers_data):
|
|
if layer and isinstance(layer, dict) and idx >= 1:
|
|
out[idx] = {
|
|
"x": layer.get("x", 0),
|
|
"y": layer.get("y", 0),
|
|
"scale": layer.get("scale", 100.0),
|
|
"scale_x": layer.get("scale_x", layer.get("scale", 100.0)),
|
|
"scale_y": layer.get("scale_y", layer.get("scale", 100.0)),
|
|
"constrain_homothety": layer.get("constrain_homothety", True),
|
|
"collapsed": layer.get("collapsed", False),
|
|
"rot": layer.get("rot", 0.0),
|
|
"opacity": layer.get("opacity", 1.0),
|
|
"visible": visible_to_bool(layer.get("visible", True), True),
|
|
"edit": visible_to_bool(layer.get("edit", False), False),
|
|
"anchor": normalize_anchor(layer.get("anchor", "top_left")),
|
|
"blend": normalize_blend_mode(layer.get("blend", "normal")),
|
|
}
|
|
|
|
# Fallback sur les kwargs
|
|
for i in range(1, MAX_LAYERS + 1):
|
|
p = f"L{i}_"
|
|
if i not in out:
|
|
if f"{p}image" in kwargs or f"{p}x" in kwargs:
|
|
out[i] = {
|
|
"x": kwargs.get(f"{p}x", 0),
|
|
"y": kwargs.get(f"{p}y", 0),
|
|
"scale": kwargs.get(f"{p}scale", 100.0),
|
|
"scale_x": kwargs.get(f"{p}scale_x", kwargs.get(f"{p}scale", 100.0)),
|
|
"scale_y": kwargs.get(f"{p}scale_y", kwargs.get(f"{p}scale", 100.0)),
|
|
"constrain_homothety": kwargs.get(f"{p}constrain_homothety", True),
|
|
"collapsed": kwargs.get(f"{p}collapsed", False),
|
|
"rot": kwargs.get(f"{p}rot", 0.0),
|
|
"opacity": kwargs.get(f"{p}opacity", 1.0),
|
|
"visible": visible_to_bool(kwargs.get(f"{p}visible", True), True),
|
|
"edit": visible_to_bool(kwargs.get(f"{p}edit", False), False),
|
|
"anchor": normalize_anchor(kwargs.get(f"{p}anchor", "top_left")),
|
|
"blend": normalize_blend_mode(kwargs.get(f"{p}blend", "normal")),
|
|
}
|
|
else:
|
|
out[i] = _default_layer()
|
|
|
|
return out
|
|
|
|
|
|
def parse_layer_order(layer_config: Any) -> list[int]:
|
|
data: dict[str, Any] = {}
|
|
if isinstance(layer_config, str) and layer_config.strip():
|
|
try:
|
|
parsed = json.loads(layer_config)
|
|
if isinstance(parsed, dict):
|
|
data = parsed
|
|
except Exception:
|
|
data = {}
|
|
elif isinstance(layer_config, dict):
|
|
data = layer_config
|
|
|
|
order: list[int] = []
|
|
raw_order = data.get("order", [])
|
|
if isinstance(raw_order, list):
|
|
for item in raw_order:
|
|
try:
|
|
idx = int(item)
|
|
except Exception:
|
|
continue
|
|
if 1 <= idx <= MAX_LAYERS and idx not in order:
|
|
order.append(idx)
|
|
for idx in range(1, MAX_LAYERS + 1):
|
|
if idx not in order:
|
|
order.append(idx)
|
|
return order
|
|
|
|
|
|
def parse_solo_layer(layer_config: Any) -> int | None:
|
|
data: dict[str, Any] = {}
|
|
if isinstance(layer_config, str) and layer_config.strip():
|
|
try:
|
|
parsed = json.loads(layer_config)
|
|
if isinstance(parsed, dict):
|
|
data = parsed
|
|
except Exception:
|
|
data = {}
|
|
elif isinstance(layer_config, dict):
|
|
data = layer_config
|
|
try:
|
|
index = int(data.get("solo_layer"))
|
|
except Exception:
|
|
return None
|
|
return index if 1 <= index <= MAX_LAYERS else None
|
|
|
|
|
|
def anchor_point(width: float, height: float, anchor: Any) -> tuple[float, float]:
|
|
anchor = normalize_anchor(anchor)
|
|
points = {
|
|
"top_left": (0.0, 0.0),
|
|
"top_right": (float(width), 0.0),
|
|
"bottom_left": (0.0, float(height)),
|
|
"bottom_right": (float(width), float(height)),
|
|
"center": (float(width) / 2.0, float(height) / 2.0),
|
|
}
|
|
return points[anchor]
|
|
|
|
|
|
def _rotated_bounds(width: float, height: float, anchor_xy: tuple[float, float], rot_deg: float) -> tuple[float, float, float, float]:
|
|
ax, ay = anchor_xy
|
|
theta = math.radians(-float(rot_deg))
|
|
cos_t = math.cos(theta)
|
|
sin_t = math.sin(theta)
|
|
corners = [(0.0, 0.0), (float(width), 0.0), (float(width), float(height)), (0.0, float(height))]
|
|
xs, ys = [], []
|
|
for x, y in corners:
|
|
dx, dy = x - ax, y - ay
|
|
xs.append(ax + dx * cos_t - dy * sin_t)
|
|
ys.append(ay + dx * sin_t + dy * cos_t)
|
|
return min(xs), min(ys), max(xs), max(ys)
|
|
|
|
|
|
def transform_layer_with_anchor(
|
|
pil_img: Image.Image,
|
|
scale_x_percent: float,
|
|
scale_y_percent: float,
|
|
rot_deg: float,
|
|
anchor: Any,
|
|
) -> tuple[Image.Image, tuple[float, float]]:
|
|
scale_x = max(float(scale_x_percent) / 100.0, 0.001)
|
|
scale_y = max(float(scale_y_percent) / 100.0, 0.001)
|
|
if abs(scale_x - 1.0) > 1e-8 or abs(scale_y - 1.0) > 1e-8:
|
|
new_w = max(1, int(round(pil_img.width * scale_x)))
|
|
new_h = max(1, int(round(pil_img.height * scale_y)))
|
|
pil_img = pil_img.resize((new_w, new_h), Image.BICUBIC)
|
|
|
|
ax, ay = anchor_point(pil_img.width, pil_img.height, anchor)
|
|
min_x, min_y, _, _ = _rotated_bounds(pil_img.width, pil_img.height, (ax, ay), float(rot_deg))
|
|
|
|
if abs(float(rot_deg)) > 1e-8:
|
|
pil_img = pil_img.rotate(-float(rot_deg), expand=True, resample=Image.BICUBIC, center=(ax, ay))
|
|
|
|
return pil_img, (ax - min_x, ay - min_y)
|
|
|
|
def transform_layer_canvas_exact(
|
|
pil_img: Image.Image,
|
|
canvas_width: int,
|
|
canvas_height: int,
|
|
x: float,
|
|
y: float,
|
|
scale_x_percent: float,
|
|
scale_y_percent: float,
|
|
rot_deg: float,
|
|
anchor: Any,
|
|
) -> Image.Image:
|
|
"""Render a layer with the same transform order as the JS preview canvas.
|
|
|
|
Frontend drawing order is:
|
|
translate(x, y) -> rotate(rot) -> scale(scale_x, scale_y) -> drawImage(-anchor_x, -anchor_y)
|
|
|
|
The previous backend implementation resized first and then used PIL.rotate(expand=True).
|
|
That is close for simple cases, but it can drift from the browser preview when several
|
|
rotated/scaled layers are stacked. This affine transform maps destination canvas pixels
|
|
back to source image pixels, matching the Canvas 2D transform directly.
|
|
"""
|
|
canvas_width = int(canvas_width)
|
|
canvas_height = int(canvas_height)
|
|
scale_x = max(float(scale_x_percent) / 100.0, 0.001)
|
|
scale_y = max(float(scale_y_percent) / 100.0, 0.001)
|
|
|
|
ax, ay = anchor_point(pil_img.width, pil_img.height, anchor)
|
|
theta = math.radians(float(rot_deg))
|
|
cos_t = math.cos(theta)
|
|
sin_t = math.sin(theta)
|
|
x = float(x)
|
|
y = float(y)
|
|
|
|
# PIL Image.transform expects the inverse affine matrix:
|
|
# source_x = a * dst_x + b * dst_y + c
|
|
# source_y = d * dst_x + e * dst_y + f
|
|
matrix = (
|
|
cos_t / scale_x,
|
|
sin_t / scale_x,
|
|
ax - (cos_t * x + sin_t * y) / scale_x,
|
|
-sin_t / scale_y,
|
|
cos_t / scale_y,
|
|
ay + (sin_t * x - cos_t * y) / scale_y,
|
|
)
|
|
|
|
try:
|
|
resample = Image.Resampling.BICUBIC
|
|
except AttributeError:
|
|
resample = Image.BICUBIC
|
|
|
|
return pil_img.transform(
|
|
(canvas_width, canvas_height),
|
|
Image.AFFINE,
|
|
matrix,
|
|
resample=resample,
|
|
fillcolor=(0, 0, 0, 0),
|
|
)
|
|
|
|
|
|
def _blend_rgb(base_rgb: np.ndarray, src_rgb: np.ndarray, mode: str) -> np.ndarray:
|
|
mode = normalize_blend_mode(mode)
|
|
if mode == "normal":
|
|
return src_rgb
|
|
if mode == "multiply":
|
|
return base_rgb * src_rgb
|
|
if mode == "screen":
|
|
return 1.0 - (1.0 - base_rgb) * (1.0 - src_rgb)
|
|
if mode == "overlay":
|
|
return np.where(base_rgb <= 0.5, 2.0 * base_rgb * src_rgb, 1.0 - 2.0 * (1.0 - base_rgb) * (1.0 - src_rgb))
|
|
if mode == "darken":
|
|
return np.minimum(base_rgb, src_rgb)
|
|
if mode == "lighten":
|
|
return np.maximum(base_rgb, src_rgb)
|
|
if mode == "add":
|
|
return np.clip(base_rgb + src_rgb, 0.0, 1.0)
|
|
if mode == "subtract":
|
|
return np.clip(base_rgb - src_rgb, 0.0, 1.0)
|
|
if mode == "difference":
|
|
return np.abs(base_rgb - src_rgb)
|
|
if mode == "soft_light":
|
|
return np.clip((1.0 - 2.0 * src_rgb) * (base_rgb ** 2) + 2.0 * src_rgb * base_rgb, 0.0, 1.0)
|
|
if mode == "hard_light":
|
|
return np.where(src_rgb <= 0.5, 2.0 * base_rgb * src_rgb, 1.0 - 2.0 * (1.0 - base_rgb) * (1.0 - src_rgb))
|
|
return src_rgb
|
|
|
|
|
|
def blend_rgba_onto_canvas(canvas_rgba: Image.Image, layer_rgba: Image.Image, top_left_x: int, top_left_y: int, blend_mode: str) -> None:
|
|
canvas_arr = np.asarray(canvas_rgba, dtype=np.float32) / 255.0
|
|
layer_arr = np.asarray(layer_rgba, dtype=np.float32) / 255.0
|
|
|
|
h, w = layer_arr.shape[:2]
|
|
x0 = max(0, int(top_left_x))
|
|
y0 = max(0, int(top_left_y))
|
|
x1 = min(canvas_arr.shape[1], int(top_left_x) + w)
|
|
y1 = min(canvas_arr.shape[0], int(top_left_y) + h)
|
|
if x0 >= x1 or y0 >= y1:
|
|
return
|
|
|
|
lx0, ly0 = x0 - int(top_left_x), y0 - int(top_left_y)
|
|
lx1, ly1 = lx0 + (x1 - x0), ly0 + (y1 - y0)
|
|
|
|
base = canvas_arr[y0:y1, x0:x1, :].copy()
|
|
src = layer_arr[ly0:ly1, lx0:lx1, :].copy()
|
|
|
|
base_rgb, base_a = base[:, :, :3], base[:, :, 3:4]
|
|
src_rgb, src_a = src[:, :, :3], src[:, :, 3:4]
|
|
|
|
blended_rgb = _blend_rgb(base_rgb, src_rgb, normalize_blend_mode(blend_mode))
|
|
effective_rgb = src_rgb * (1.0 - base_a) + blended_rgb * base_a
|
|
out_a = src_a + base_a * (1.0 - src_a)
|
|
premul_rgb = effective_rgb * src_a + base_rgb * base_a * (1.0 - src_a)
|
|
out_rgb = np.divide(premul_rgb, np.maximum(out_a, 1e-6))
|
|
|
|
canvas_arr[y0:y1, x0:x1, :] = np.clip(np.concatenate([out_rgb, out_a], axis=2), 0.0, 1.0)
|
|
result = Image.fromarray((canvas_arr * 255.0).round().clip(0, 255).astype(np.uint8), mode="RGBA")
|
|
canvas_rgba.paste(result)
|
|
|
|
|
|
def apply_opacity(layer_rgba: Image.Image, opacity: float) -> Image.Image:
|
|
opacity = float(np.clip(opacity, 0.0, 1.0))
|
|
if opacity >= 0.999:
|
|
return layer_rgba
|
|
arr = np.asarray(layer_rgba, dtype=np.uint8).copy()
|
|
arr[:, :, 3] = (arr[:, :, 3].astype(np.float32) * opacity).round().clip(0, 255).astype(np.uint8)
|
|
return Image.fromarray(arr, mode="RGBA")
|
|
|
|
|
|
def composite_rgba_over_bg(pil_img: Image.Image, bg_rgba: tuple[int, int, int, int]) -> Image.Image:
|
|
src = np.asarray(pil_img.convert("RGBA"), dtype=np.float32) / 255.0
|
|
bg = np.array(bg_rgba, dtype=np.float32) / 255.0
|
|
bg_rgb = bg[:3].reshape(1, 1, 3)
|
|
src_rgb = src[:, :, :3]
|
|
src_a = src[:, :, 3:4]
|
|
out_rgb = src_rgb * src_a + bg_rgb * (1.0 - src_a)
|
|
out = np.concatenate([out_rgb, np.ones_like(src_a)], axis=2)
|
|
return Image.fromarray((out * 255.0).round().clip(0, 255).astype(np.uint8), mode="RGBA")
|
|
|
|
|
|
def _temp_directory() -> str:
|
|
try:
|
|
import folder_paths # type: ignore
|
|
return folder_paths.get_temp_directory()
|
|
except Exception:
|
|
return os.path.join(os.getcwd(), "temp")
|
|
|
|
|
|
def save_layer_cache_png(layer_rgba: Image.Image, layer_index: int) -> dict[str, Any]:
|
|
temp_dir = _temp_directory()
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
filename = f"orion4d_layer_cache_{uuid.uuid4().hex}_L{layer_index}.png"
|
|
path = os.path.join(temp_dir, filename)
|
|
layer_rgba.save(path)
|
|
return {
|
|
"layer": int(layer_index),
|
|
"filename": filename,
|
|
"subfolder": "",
|
|
"type": "temp",
|
|
}
|
|
|
|
|
|
def composite_layers_impl(canvas_width: int, canvas_height: int, bg_hex: str, flatten_output: bool, invert_input_masks: bool, layer_config: Any = "{}", **kwargs: Any) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, list[dict[str, Any]]]:
|
|
canvas_width = int(np.clip(int(canvas_width), 16, 8192))
|
|
canvas_height = int(np.clip(int(canvas_height), 16, 8192))
|
|
canvas = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0))
|
|
layer_cache: list[dict[str, Any]] = []
|
|
|
|
layers_cfg = parse_layer_config(layer_config, kwargs)
|
|
solo_layer = parse_solo_layer(layer_config)
|
|
|
|
# Bottom-to-top order can be changed from the external Layer Manager app.
|
|
for i in parse_layer_order(layer_config):
|
|
if solo_layer is not None and i != solo_layer:
|
|
continue
|
|
layer = layers_cfg.get(i, {})
|
|
if not visible_to_bool(layer.get("visible", True)):
|
|
continue
|
|
|
|
layer_rgba = tensor_image_to_pil_rgba(kwargs.get(f"L{i}_image"))
|
|
if layer_rgba is None:
|
|
continue
|
|
|
|
layer_rgba = apply_external_mask_to_rgba(layer_rgba, kwargs.get(f"L{i}_mask"), invert_mask=bool(invert_input_masks))
|
|
try:
|
|
layer_cache.append(save_layer_cache_png(layer_rgba, i))
|
|
except Exception:
|
|
pass
|
|
|
|
sx = layer.get("scale_x", layer.get("scale", 100.0))
|
|
sy = layer.get("scale_y", layer.get("scale", 100.0))
|
|
if visible_to_bool(layer.get("constrain_homothety", True)):
|
|
sx = sy = layer.get("scale", sx)
|
|
|
|
layer_rgba = apply_opacity(layer_rgba, layer.get("opacity", 1.0))
|
|
transformed = transform_layer_canvas_exact(
|
|
layer_rgba,
|
|
canvas_width,
|
|
canvas_height,
|
|
layer.get("x", 0),
|
|
layer.get("y", 0),
|
|
sx,
|
|
sy,
|
|
layer.get("rot", 0.0),
|
|
layer.get("anchor", "top_left"),
|
|
)
|
|
blend_rgba_onto_canvas(canvas, transformed, 0, 0, layer.get("blend", "normal"))
|
|
|
|
straight_rgb, image_rgba, alpha = pil_rgba_to_tensors(canvas)
|
|
if flatten_output:
|
|
image_bg = pil_rgb_tensor(composite_rgba_over_bg(canvas, parse_bg(bg_hex)))
|
|
else:
|
|
image_bg = straight_rgb
|
|
return image_bg, image_rgba, alpha, layer_cache
|
|
|
|
|
|
class LayerManagerNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"canvas_width": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}),
|
|
"canvas_height": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}),
|
|
"bg_hex": ("STRING", {"default": "#FFFFFF", "multiline": False}),
|
|
"flatten_output": ("BOOLEAN", {"default": True}),
|
|
"invert_input_masks": ("BOOLEAN", {"default": False}),
|
|
"layer_config": ("STRING", {"default": "{}", "multiline": True}),
|
|
},
|
|
"optional": {
|
|
"L1_image": ("IMAGE",),
|
|
"L1_mask": ("MASK",),
|
|
# Les ports L2, L3 s'ajouteront dynamiquement par kwargs.
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK")
|
|
RETURN_NAMES = ("image_bg", "image_rgba", "alpha")
|
|
FUNCTION = "composite_layers"
|
|
CATEGORY = CATEGORY
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(cls, **kwargs):
|
|
return True
|
|
|
|
def composite_layers(self, canvas_width, canvas_height, bg_hex, flatten_output=True, invert_input_masks=False, layer_config="{}", **kwargs):
|
|
image_bg, image_rgba, alpha, layer_cache = composite_layers_impl(
|
|
canvas_width,
|
|
canvas_height,
|
|
bg_hex,
|
|
flatten_output,
|
|
invert_input_masks,
|
|
layer_config,
|
|
**kwargs,
|
|
)
|
|
return {
|
|
"ui": {"orion_layer_cache": layer_cache},
|
|
"result": (image_bg, image_rgba, alpha),
|
|
}
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {NODE_ID: LayerManagerNode}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: DISPLAY_NAME}
|
|
|