Files
2025-11-11 10:18:13 +08:00

451 lines
15 KiB
Python

"""
ComfyUI custom nodes for embedding images into PSD smart object mockups.
The main node takes a PSD mockup file together with an input image, finds
smart-object layers, and replaces their content by warping the input image
to the smart-object's perspective box.
"""
from __future__ import annotations
import json
import math
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable, List, Optional, Sequence, Tuple
import numpy as np
from PIL import Image, ImageDraw
from psd_tools import PSDImage
from psd_tools.api.layers import Layer, SmartObjectLayer
try:
import torch
except Exception: # pragma: no cover - torch is always available inside ComfyUI
torch = None
try:
import folder_paths
except Exception: # pragma: no cover - allows running outside ComfyUI
folder_paths = None
ALLOWED_PSD_EXTENSIONS = (".psd", ".psb")
QUAD_INDICES = ((0, 1), (2, 3), (4, 5), (6, 7))
PNG_SUFFIX = ".png"
@dataclass
class SmartLayerInfo:
"""Small helper that keeps metadata for a smart-object layer."""
layer_id: int
name: str
visible: bool
bbox: Tuple[int, int, int, int]
transform_box: Optional[Tuple[float, ...]]
def to_dict(self) -> dict:
return {
"layer_id": self.layer_id,
"name": self.name,
"visible": self.visible,
"bbox": self.bbox,
"transform_box": self.transform_box,
}
def _resolve_input_path(filename: str, allowed_exts: Sequence[str]) -> str:
if not filename:
raise ValueError("A file name must be provided.")
if os.path.isabs(filename):
candidate = filename
if not os.path.exists(candidate):
raise FileNotFoundError(f"File not found: {candidate}")
else:
search_roots: List[str] = []
if folder_paths:
getter = getattr(folder_paths, "get_input_directory", None)
if callable(getter):
search_roots.append(getter())
get_extra = getattr(folder_paths, "get_input_directories", None)
if callable(get_extra):
search_roots.extend(get_extra())
search_roots.append(os.getcwd())
candidate = ""
for root in search_roots:
path = os.path.join(root, filename)
if os.path.exists(path):
candidate = path
break
if not candidate:
raise FileNotFoundError(
f"Unable to resolve '{filename}'. Place the file under ComfyUI/input or provide an absolute path."
)
_, ext = os.path.splitext(candidate)
ext = ext.lower()
if allowed_exts and ext not in allowed_exts:
raise ValueError(f"Unsupported file extension '{ext}'. Expected one of {allowed_exts}.")
return candidate
def _list_input_files(allowed_exts: Sequence[str]) -> List[str]:
if not folder_paths or not hasattr(folder_paths, "get_filename_list"):
return []
try:
files = folder_paths.get_filename_list("input")
except Exception:
return []
if not allowed_exts:
return files
allowed = tuple(ext.lower() for ext in allowed_exts)
filtered = [name for name in files if name.lower().endswith(allowed)]
return filtered or files
def _collect_smart_layers(group: Iterable[Layer]) -> List[SmartLayerInfo]:
layers: List[SmartLayerInfo] = []
def walk(sub_layers: Iterable[Layer]):
for layer in sub_layers:
if isinstance(layer, SmartObjectLayer):
layers.append(
SmartLayerInfo(
layer_id=layer.layer_id,
name=layer.name,
visible=layer.is_visible(),
bbox=tuple(map(int, layer.bbox)),
transform_box=layer.smart_object.transform_box,
)
)
if layer.is_group():
walk(layer)
walk(group)
return layers
def _tensor_to_pil(image_tensor) -> Image.Image:
if image_tensor is None:
return None
if torch is None:
raise RuntimeError("torch is required to convert IMAGE values. Are you running inside ComfyUI?")
tensor = image_tensor
if isinstance(tensor, list):
tensor = tensor[0]
if hasattr(tensor, "detach"):
tensor = tensor.detach().cpu()
array = np.clip(tensor.numpy(), 0.0, 1.0)
if array.ndim == 4:
array = array[0]
array = (array * 255).astype(np.uint8)
if array.shape[-1] == 1:
array = np.repeat(array, 3, axis=-1)
return Image.fromarray(array)
def _pil_to_tensor(image: Image.Image):
if torch is None:
raise RuntimeError("torch is required to convert PIL images back to ComfyUI tensors.")
rgb = image.convert("RGB")
array = np.array(rgb).astype(np.float32) / 255.0
tensor = torch.from_numpy(array)[None, ...]
return tensor
def _load_image_from_path(path: str) -> Image.Image:
if not os.path.exists(path):
raise FileNotFoundError(f"Image not found: {path}")
image = Image.open(path)
return image.convert("RGBA")
def _normalize_name(name: str) -> str:
return name.strip().lower()
def _select_layers(layers: List[SmartLayerInfo], names: Sequence[str]) -> List[SmartLayerInfo]:
if not names or len(names) == 0:
return layers[:1] if layers else []
lookup = {_normalize_name(name) for name in names if name.strip()}
if not lookup:
return layers[:1] if layers else []
result = [layer for layer in layers if _normalize_name(layer.name) in lookup]
if not result:
return layers[:1] if layers else []
return result
def _split_names(raw_names: str) -> List[str]:
if not raw_names:
return []
return [part.strip() for part in raw_names.split(",") if part.strip()]
def _warp_image_to_canvas(image: Image.Image, quad: Sequence[float], canvas_size: Tuple[int, int]) -> Image.Image:
try:
import cv2
except Exception as exc: # pragma: no cover - cv2 is installed via requirements
raise RuntimeError("opencv-python-headless is required for perspective warping.") from exc
if quad is None or len(quad) != 8:
raise ValueError("transform_box must contain eight values.")
width, height = canvas_size
if width <= 0 or height <= 0:
raise ValueError("Canvas size must be positive.")
image_rgba = image.convert("RGBA")
src_w, src_h = image_rgba.size
src_pts = np.float32([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]])
dst_pts = np.float32([[quad[i], quad[i + 1]] for i, _ in QUAD_INDICES])
matrix = cv2.getPerspectiveTransform(src_pts, dst_pts)
canvas_w, canvas_h = int(math.ceil(width)), int(math.ceil(height))
rgba_array = np.array(image_rgba)
warped = cv2.warpPerspective(
rgba_array,
matrix,
(canvas_w, canvas_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0, 0),
)
return Image.fromarray(warped, mode="RGBA")
def _compose_without_layers(psd: PSDImage, skip_ids: Sequence[int]) -> Image.Image:
skip_set = set(skip_ids)
def layer_filter(layer: Layer) -> bool:
if hasattr(layer, "layer_id") and layer.layer_id in skip_set:
return False
return layer.is_visible()
return psd.composite(layer_filter=layer_filter)
def _polygon_mask(quad: Sequence[float], canvas_size: Tuple[int, int]) -> Image.Image:
mask = Image.new("L", tuple(map(int, canvas_size)), 0)
draw = ImageDraw.Draw(mask)
draw.polygon([(quad[i], quad[i + 1]) for i, _ in QUAD_INDICES], fill=255)
return mask
def _build_layer_lookup(psd: PSDImage) -> dict:
lookup = {}
for layer in psd.descendants():
layer_id = getattr(layer, "layer_id", None)
if layer_id is not None:
lookup[layer_id] = layer
return lookup
def _collect_clipping_stack(layer: Layer) -> List[Layer]:
stack = [layer]
parent = layer.parent
if parent is None:
return stack
siblings = list(parent)
try:
idx = siblings.index(layer)
except ValueError:
return stack
for sib in siblings[idx + 1 :]:
if not getattr(sib, "clipping", False):
break
stack.append(sib)
return stack
def _composite_layers(psd: PSDImage, layer_ids: Sequence[int]) -> Image.Image:
include = set(layer_ids)
def filter_fn(layer: Layer) -> bool:
if hasattr(layer, "layer_id") and layer.layer_id in include:
return True
return False
return psd.composite(layer_filter=filter_fn)
def _alpha_mask_from_image(image: Image.Image) -> Optional[Image.Image]:
if image is None:
return None
if image.mode != "RGBA":
image = image.convert("RGBA")
alpha = image.split()[-1]
if not alpha.getbbox():
return None
return alpha
def _output_directory() -> str:
if folder_paths and hasattr(folder_paths, "get_output_directory"):
return folder_paths.get_output_directory()
default_dir = os.path.join(os.getcwd(), "output")
os.makedirs(default_dir, exist_ok=True)
return default_dir
def _save_png(image: Image.Image, psd_path: str) -> str:
base_name = Path(psd_path).stem or "psd_mockup"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{base_name}_{timestamp}{PNG_SUFFIX}"
out_dir = _output_directory()
out_path = os.path.join(out_dir, filename)
image.save(out_path, "PNG")
return out_path
class PSDSmartObjectInspector:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"psd_file": ("STRING", {"default": "", "multiline": False}),
"include_hidden": ("BOOLEAN", {"default": False}),
}
}
CATEGORY = "psd/mockup"
RETURN_TYPES = ("STRING",)
FUNCTION = "inspect"
def inspect(self, psd_file: str, include_hidden: bool):
psd_path = _resolve_input_path(psd_file, ALLOWED_PSD_EXTENSIONS)
psd = PSDImage.open(psd_path, strict=False)
layers = _collect_smart_layers(psd)
payload = []
for layer in layers:
if not include_hidden and not layer.visible:
continue
payload.append(layer.to_dict())
return (json.dumps({"psd": os.path.basename(psd_path), "layers": payload}, indent=2),)
class PSDMockupEmbedder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"psd_file": (
"STRING",
{
"default": "",
"multiline": False,
"placeholder": "Upload or paste PSD/PSB path",
"tooltip": "Use the built-in upload button or paste a path manually",
},
),
"mockup_image": ("IMAGE",),
},
"optional": {
"smart_object_names": ("STRING", {"default": "", "multiline": False}),
"output_width": (
"INT",
{
"default": 800,
"min": 0,
"max": 8192,
"step": 1,
"tooltip": "Resize final image to this width (0 keeps original size)",
},
),
},
}
CATEGORY = "psd/mockup"
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("mockup_image", "debug_info", "png_path")
FUNCTION = "apply"
def apply(
self,
psd_file: str,
mockup_image,
smart_object_names: str = "",
output_width: int = 800,
):
resolved_psd = _resolve_input_path(psd_file, ALLOWED_PSD_EXTENSIONS)
psd = PSDImage.open(resolved_psd, strict=False)
source_image = _tensor_to_pil(mockup_image)
if source_image is None:
raise ValueError("mockup_image input is required. Use MockupImageUpload or any IMAGE source.")
layers = _collect_smart_layers(psd)
requested_names = _split_names(smart_object_names)
selected = _select_layers(layers, requested_names)
if not selected:
raise ValueError("No smart-object layers available inside the PSD.")
fallback_used = False
if requested_names:
requested_norm = {_normalize_name(name) for name in requested_names}
selected_norm = {_normalize_name(layer.name) for layer in selected}
fallback_used = not bool(requested_norm & selected_norm)
layer_lookup = _build_layer_lookup(psd)
canvas_size = psd.size
base = _compose_without_layers(psd, [info.layer_id for info in selected])
base_rgba = base.convert("RGBA")
metadata = []
for info in selected:
layer = layer_lookup.get(info.layer_id)
if layer is None:
continue
quad = info.transform_box
if quad is None:
left, top, right, bottom = info.bbox
quad = (left, top, right, top, right, bottom, left, bottom)
stack_layers = _collect_clipping_stack(layer)
overlay_layers = stack_layers[1:] if len(stack_layers) > 1 else []
overlay_ids = [l.layer_id for l in overlay_layers if hasattr(l, "layer_id")]
overlay_image = _composite_layers(psd, overlay_ids) if overlay_ids else None
base_image = _composite_layers(psd, [layer.layer_id])
alpha_mask = _alpha_mask_from_image(base_image) or _polygon_mask(quad, canvas_size)
warped = _warp_image_to_canvas(source_image, quad, canvas_size)
base_rgba = Image.composite(warped, base_rgba, alpha_mask)
if overlay_image is not None:
base_rgba = Image.alpha_composite(base_rgba, overlay_image.convert("RGBA"))
metadata.append(
{
"layer_id": info.layer_id,
"name": info.name,
"quad": quad,
}
)
if isinstance(output_width, (int, float)) and output_width and output_width > 0:
target_w = int(output_width)
if target_w > 0 and target_w != base_rgba.width:
ratio = target_w / base_rgba.width
target_h = max(1, int(round(base_rgba.height * ratio)))
base_rgba = base_rgba.resize((target_w, target_h), Image.LANCZOS)
result_tensor = _pil_to_tensor(base_rgba)
saved_path = _save_png(base_rgba, resolved_psd)
debug_payload = {
"psd": os.path.basename(resolved_psd),
"layer_count": len(selected),
"layers": metadata,
"output_size": {"width": base_rgba.width, "height": base_rgba.height},
}
if fallback_used:
debug_payload["note"] = "Requested smart object names were not found; falling back to the first smart object layer."
return (result_tensor, json.dumps(debug_payload, indent=2), saved_path)
NODE_CLASS_MAPPINGS = {
"PSDSmartObjectInspector": PSDSmartObjectInspector,
"PSDMockupEmbedder": PSDMockupEmbedder,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PSDSmartObjectInspector": "PSD → Smart Object Info",
"PSDMockupEmbedder": "PSD Mockup Embedder",
}