- Clipping stacks now form at document root and inside custom groups (previously only inside PSD folders) via renderIdList run-grouping - Server-side composite respects hidden clipping layers (layer_filter) - Layer images use composite(layer_filter=self) instead of topil(): layer masks/opacity applied again; clipping layers composited with the clipping flag temporarily cleared - Offscreen clipping canvas cached (was re-allocated per frame) - countSwSlots unified with expandSwGroupEntries; isOrphaned simplified - server.py layer tree delegates to psd_utils.get_layer_tree - Composite errors logged instead of silently swallowed - Removed dead i18n key addGroupTooltip (ja/en/zh) - output_width/output_height step 64 -> 1 (1px adjustable) - DEVLOG v2.24.0 entry; README (ja/en/zh) updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
5.3 KiB
Python
128 lines
5.3 KiB
Python
import base64
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
import folder_paths
|
|
from .psd_utils import composite_with_config, pil_rgb_to_tensor, pil_mask_to_tensor
|
|
|
|
|
|
def _composite_on_bg(psd_rgba: Image.Image, background_image, out_w: int, out_h: int) -> Image.Image:
|
|
"""background_image テンソルをキャンバス背景として psd_rgba を上に合成して返す。"""
|
|
bg_np = (background_image[0].cpu().numpy() * 255).clip(0, 255).astype("uint8")
|
|
bg_pil = Image.fromarray(bg_np, "RGB").convert("RGBA")
|
|
bg_w, bg_h = bg_pil.size
|
|
scale = min(out_w / bg_w, out_h / bg_h)
|
|
new_bw = max(1, int(bg_w * scale))
|
|
new_bh = max(1, int(bg_h * scale))
|
|
bg_resized = bg_pil.resize((new_bw, new_bh), Image.LANCZOS)
|
|
canvas = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 255))
|
|
canvas.paste(bg_resized, ((out_w - new_bw) // 2, (out_h - new_bh) // 2))
|
|
return Image.alpha_composite(canvas, psd_rgba)
|
|
|
|
|
|
class PSDFigureCreatorNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {},
|
|
"optional": {
|
|
"psd_filename": ("STRING", {"default": ""}),
|
|
"layer_config": ("STRING", {"default": "{}"}),
|
|
"output_width": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 1}),
|
|
"output_height": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 1}),
|
|
"image_data": ("STRING", {"default": ""}),
|
|
"background_image": ("IMAGE",),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK")
|
|
RETURN_NAMES = ("image", "mask")
|
|
FUNCTION = "load_psd"
|
|
CATEGORY = "image/psd"
|
|
|
|
def load_psd(self, psd_filename="", layer_config="{}", output_width=0, output_height=0,
|
|
image_data="", unique_id=None, background_image=None):
|
|
# image_data が設定されている場合はクライアントキャプチャを使用
|
|
# background_image がある場合はその下に合成する(最優先)
|
|
if image_data and image_data.strip():
|
|
try:
|
|
data = image_data
|
|
if "," in data:
|
|
data = data.split(",", 1)[1]
|
|
decoded = base64.b64decode(data)
|
|
if len(decoded) > 8 and decoded.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
pil = Image.open(io.BytesIO(decoded)).convert("RGBA")
|
|
if output_width > 0 and output_height > 0 and pil.size != (output_width, output_height):
|
|
pil = pil.resize((output_width, output_height), Image.LANCZOS)
|
|
out_w, out_h = pil.size
|
|
if background_image is not None:
|
|
pil = _composite_on_bg(pil, background_image, out_w, out_h)
|
|
rgb = pil.convert("RGB")
|
|
alpha = pil.split()[-1]
|
|
return (pil_rgb_to_tensor(rgb), pil_mask_to_tensor(alpha))
|
|
except Exception as e:
|
|
print(f"[PSDFigureCreator] image_data decode error: {e}")
|
|
|
|
# 通常の server-side compositing
|
|
if not psd_filename:
|
|
raise ValueError("PSDファイルが指定されていません。ノードのボタンからファイルを選択してください。")
|
|
|
|
psd_dir = Path(folder_paths.get_input_directory()) / "psd"
|
|
psd_path = psd_dir / Path(psd_filename).name
|
|
|
|
if not psd_path.exists():
|
|
raise FileNotFoundError(f"PSDファイルが見つかりません: {psd_path}")
|
|
|
|
try:
|
|
config = json.loads(layer_config) if layer_config else {}
|
|
except json.JSONDecodeError:
|
|
config = {}
|
|
|
|
rgb, alpha = composite_with_config(str(psd_path), config)
|
|
|
|
if output_width > 0 or output_height > 0:
|
|
w, h = rgb.size
|
|
if output_width > 0 and output_height > 0:
|
|
new_w, new_h = output_width, output_height
|
|
elif output_width > 0:
|
|
new_h = max(1, int(h * output_width / w))
|
|
new_w = output_width
|
|
else:
|
|
new_w = max(1, int(w * output_height / h))
|
|
new_h = output_height
|
|
rgb = rgb.resize((new_w, new_h), Image.LANCZOS)
|
|
alpha = alpha.resize((new_w, new_h), Image.LANCZOS)
|
|
|
|
# background_image があれば最下層に合成
|
|
if background_image is not None:
|
|
out_w, out_h = rgb.size
|
|
psd_rgba = rgb.convert("RGBA")
|
|
psd_rgba.putalpha(alpha)
|
|
result = _composite_on_bg(psd_rgba, background_image, out_w, out_h)
|
|
rgb = result.convert("RGB")
|
|
alpha = result.split()[-1]
|
|
|
|
return (pil_rgb_to_tensor(rgb), pil_mask_to_tensor(alpha))
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, psd_filename="", layer_config="{}", output_width=0, output_height=0,
|
|
image_data="", unique_id=None, background_image=None):
|
|
import hashlib
|
|
bg_key = str(background_image.shape) if background_image is not None else ""
|
|
key = f"{psd_filename}|{layer_config}|{output_width}|{output_height}|{image_data}|{bg_key}"
|
|
return hashlib.md5(key.encode()).hexdigest()
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"PSDFigureCreator": PSDFigureCreatorNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"PSDFigureCreator": "PSD Figure Creator",
|
|
}
|