Files
ketle-manandClaude Fable 5 45c84ae0c5 fix: code-review fixes for clipping/masks/perf, 1px output size (v0.4.0)
- 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>
2026-06-10 20:59:18 +09:00

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",
}