853 lines
31 KiB
Python
853 lines
31 KiB
Python
import hashlib
|
|
import json
|
|
import math
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
from comfy_api.latest import ComfyExtension, io, UI
|
|
from comfy_extras.compositor_blend import (
|
|
_LAYER_MODES,
|
|
blend_composite,
|
|
linear_to_srgb,
|
|
placed_bounds,
|
|
resolve_mode,
|
|
srgb_to_linear,
|
|
)
|
|
from comfy_extras.color_util import hex_to_rgb
|
|
from comfy_extras.nodes_bounding_boxes import boxes_from_input
|
|
from nodes import MAX_RESOLUTION
|
|
from typing_extensions import override
|
|
|
|
|
|
MAX_LAYERS = 50
|
|
|
|
|
|
def document_items(doc) -> list[dict]:
|
|
if not isinstance(doc, dict):
|
|
return []
|
|
version = doc.get("version")
|
|
if version is not None and version != 1:
|
|
raise ValueError(f"LAYERS document version {version!r} is not supported")
|
|
items = []
|
|
for item in doc.get("layers") or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
item_type = item.get("type", "raster")
|
|
if item_type != "raster":
|
|
raise ValueError(f"LAYERS item type {item_type!r} is not supported yet")
|
|
if not isinstance(item.get("image"), torch.Tensor):
|
|
continue
|
|
blend = item.get("blend_mode")
|
|
if blend is not None and blend not in _LAYER_MODES:
|
|
raise ValueError(f"LAYERS item blend_mode {blend!r} is not a known blend mode")
|
|
items.append(item)
|
|
return sorted(items, key=lambda item: _int(item.get("z_index"), 0))
|
|
|
|
|
|
def document_canvas(doc) -> tuple[int, int] | None:
|
|
if not isinstance(doc, dict):
|
|
return None
|
|
canvas = doc.get("canvas")
|
|
if not isinstance(canvas, (tuple, list)) or len(canvas) != 2:
|
|
return None
|
|
w, h = _int(canvas[0], 0), _int(canvas[1], 0)
|
|
return (w, h) if w > 0 and h > 0 else None
|
|
|
|
|
|
def _int(value, default: int) -> int:
|
|
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else default
|
|
|
|
|
|
def _bbox_list(bboxes, canvas_width: int, canvas_height: int) -> list[dict]:
|
|
if bboxes is None:
|
|
return []
|
|
if isinstance(bboxes, str):
|
|
text = bboxes.strip()
|
|
if not text:
|
|
return []
|
|
try:
|
|
bboxes = json.loads(text)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
raise ValueError(f"bboxes string input is not valid JSON: {exc}") from exc
|
|
probe = bboxes if isinstance(bboxes, list) else [bboxes]
|
|
if probe and isinstance(probe[0], list):
|
|
probe = probe[0]
|
|
has_elements = any(
|
|
isinstance(box, dict) and isinstance(box.get("bbox"), (list, tuple))
|
|
for box in probe
|
|
)
|
|
if has_elements and (canvas_width <= 0 or canvas_height <= 0):
|
|
raise ValueError(
|
|
"normalized element boxes need canvas_width and canvas_height to resolve to pixels"
|
|
)
|
|
return boxes_from_input(bboxes, canvas_width, canvas_height)
|
|
|
|
|
|
def _item_mask_frame(mask, index: int) -> torch.Tensor | None:
|
|
if not isinstance(mask, torch.Tensor):
|
|
return None
|
|
if mask.shape[0] == 1:
|
|
return mask[:1]
|
|
if index < mask.shape[0]:
|
|
return mask[index : index + 1]
|
|
return None
|
|
|
|
|
|
def expand_item_frames(items: list[dict]) -> list[dict]:
|
|
frames = []
|
|
for item in items:
|
|
image = item["image"]
|
|
for index in range(image.shape[0]):
|
|
width = _int(item.get("w"), 0)
|
|
height = _int(item.get("h"), 0)
|
|
rotation = item.get("rotation")
|
|
frames.append({
|
|
"tensor": image[index : index + 1],
|
|
"mask": _item_mask_frame(item.get("mask"), index),
|
|
"name": item.get("name") if isinstance(item.get("name"), str) else None,
|
|
"x": _int(item.get("x"), 0),
|
|
"y": _int(item.get("y"), 0),
|
|
"w": width if width > 0 else int(image.shape[2]),
|
|
"h": height if height > 0 else int(image.shape[1]),
|
|
"rotation": float(rotation)
|
|
if isinstance(rotation, (int, float)) and not isinstance(rotation, bool)
|
|
else 0.0,
|
|
"opacity": item.get("opacity", 1.0),
|
|
"blend": item.get("blend_mode", "normal"),
|
|
"visible": item.get("visible", True),
|
|
"flip_h": bool(item.get("flip_h", False)),
|
|
"flip_v": bool(item.get("flip_v", False)),
|
|
})
|
|
if len(frames) > MAX_LAYERS:
|
|
raise ValueError(
|
|
f"Compositor supports at most {MAX_LAYERS} layers, got {len(frames)}"
|
|
)
|
|
return frames
|
|
|
|
|
|
def frame_alpha(
|
|
tensor: torch.Tensor, mask: torch.Tensor | None
|
|
) -> torch.Tensor | None:
|
|
alpha = tensor[:1, :, :, 3] if tensor.shape[-1] == 4 else None
|
|
if mask is None:
|
|
return alpha
|
|
h, w = tensor.shape[1], tensor.shape[2]
|
|
m = mask[:1].to(device=tensor.device, dtype=torch.float32)
|
|
if m.shape[1] != h or m.shape[2] != w:
|
|
m = torch.nn.functional.interpolate(
|
|
m.unsqueeze(1), size=(h, w), mode="bilinear"
|
|
).squeeze(1)
|
|
inv = torch.clamp(1.0 - m, 0.0, 1.0)
|
|
return inv if alpha is None else alpha * inv
|
|
|
|
|
|
def layer_preview_tensor(
|
|
tensor: torch.Tensor, alpha: torch.Tensor | None
|
|
) -> torch.Tensor:
|
|
rgb = tensor[:1, :, :, :3]
|
|
if alpha is None:
|
|
return rgb
|
|
return torch.cat([rgb, alpha.unsqueeze(-1)], dim=-1)
|
|
|
|
|
|
def canvas_extent(frames: list[dict]) -> tuple[int, int]:
|
|
right = 1
|
|
bottom = 1
|
|
for frame in frames:
|
|
bx, by, bw, bh = placed_bounds(
|
|
frame["x"], frame["y"], frame["w"], frame["h"], frame["rotation"]
|
|
)
|
|
right = max(right, bx + bw)
|
|
bottom = max(bottom, by + bh)
|
|
return (right, bottom)
|
|
|
|
|
|
def input_fingerprints(
|
|
frames: list[dict], alphas: list[torch.Tensor | None]
|
|
) -> list[str]:
|
|
fingerprints = []
|
|
for frame, alpha in zip(frames, alphas):
|
|
tensor = frame["tensor"]
|
|
rgb = tensor[0, :, :, :3].detach().cpu().numpy()
|
|
rgb8 = np.clip(np.rint(rgb * 255.0), 0, 255).astype(np.uint8)
|
|
digest = hashlib.sha256()
|
|
digest.update(repr(tuple(tensor.shape)).encode())
|
|
digest.update(rgb8.tobytes())
|
|
if alpha is not None:
|
|
alpha8 = np.clip(
|
|
np.rint(alpha[0].detach().cpu().numpy() * 255.0), 0, 255
|
|
).astype(np.uint8)
|
|
digest.update(alpha8.tobytes())
|
|
digest.update(
|
|
repr((
|
|
frame["x"],
|
|
frame["y"],
|
|
frame["w"],
|
|
frame["h"],
|
|
frame["rotation"],
|
|
frame["opacity"],
|
|
frame["blend"],
|
|
bool(frame["visible"]),
|
|
frame["flip_h"],
|
|
frame["flip_v"],
|
|
)).encode()
|
|
)
|
|
fingerprints.append(digest.hexdigest()[:16])
|
|
return fingerprints
|
|
|
|
|
|
def state_from_items(frames: list[dict], canvas: tuple[int, int]) -> dict:
|
|
layers = []
|
|
for frame in frames:
|
|
layers.append({
|
|
"name": frame["name"],
|
|
"visible": bool(frame["visible"]),
|
|
"opacity": frame["opacity"],
|
|
"blend": frame["blend"],
|
|
"flipH": frame["flip_h"],
|
|
"flipV": frame["flip_v"],
|
|
"transform": {
|
|
"x": frame["x"],
|
|
"y": frame["y"],
|
|
"w": frame["w"],
|
|
"h": frame["h"],
|
|
"rotation": frame["rotation"],
|
|
},
|
|
})
|
|
return {
|
|
"canvas": canvas,
|
|
"layers": layers,
|
|
"inputs": None,
|
|
"background": {"color": "#ffffff", "opacity": 1.0, "visible": False},
|
|
}
|
|
|
|
|
|
def layer_ui_entries(frames: list[dict]) -> list:
|
|
entries = []
|
|
for frame in frames:
|
|
entries.append({
|
|
"x": frame["x"],
|
|
"y": frame["y"],
|
|
"width": int(frame["w"]),
|
|
"height": int(frame["h"]),
|
|
"rotation": frame["rotation"],
|
|
"name": frame["name"],
|
|
"visible": bool(frame["visible"]),
|
|
"opacity": frame["opacity"] if isinstance(frame["opacity"], (int, float)) else 1.0,
|
|
"blend": frame["blend"] if isinstance(frame["blend"], str) else "normal",
|
|
"flipH": frame["flip_h"],
|
|
"flipV": frame["flip_v"],
|
|
})
|
|
return entries
|
|
|
|
|
|
_HEX_DIGITS = set("0123456789abcdef")
|
|
|
|
|
|
def _normalize_hex_color(value) -> str:
|
|
if isinstance(value, str):
|
|
text = value.strip().lower()
|
|
if text.startswith("#"):
|
|
digits = text[1:]
|
|
if len(digits) == 3 and set(digits) <= _HEX_DIGITS:
|
|
digits = "".join(ch * 2 for ch in digits)
|
|
if len(digits) == 6 and set(digits) <= _HEX_DIGITS:
|
|
return "#" + digits
|
|
return "#ffffff"
|
|
|
|
|
|
def _parse_background(entry) -> dict | None:
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
return {
|
|
"color": _normalize_hex_color(entry.get("color")),
|
|
"opacity": min(max(_number(entry, "opacity", 1.0), 0.0), 1.0),
|
|
"visible": bool(entry.get("visible", True)),
|
|
}
|
|
|
|
|
|
def _parse_order(value, layer_count: int) -> list[int] | None:
|
|
if not isinstance(value, list) or not value:
|
|
return None
|
|
if not all(
|
|
isinstance(item, int) and not isinstance(item, bool) for item in value
|
|
):
|
|
return None
|
|
if sorted(value) != list(range(layer_count)):
|
|
return None
|
|
return value
|
|
|
|
|
|
def layer_state_provided(raw) -> bool:
|
|
if isinstance(raw, dict):
|
|
return bool(raw)
|
|
if isinstance(raw, str):
|
|
return raw not in ("", "{}")
|
|
return False
|
|
|
|
|
|
def parse_layer_state(raw) -> dict | None:
|
|
if isinstance(raw, str):
|
|
if not raw.strip():
|
|
return None
|
|
try:
|
|
raw = json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
state = raw
|
|
version = state.get("version")
|
|
if version is not None and version != 1:
|
|
return None
|
|
canvas = state.get("canvas")
|
|
layers = state.get("layers")
|
|
if not isinstance(canvas, dict) or not isinstance(layers, list) or not layers:
|
|
return None
|
|
try:
|
|
w = int(round(float(canvas.get("w"))))
|
|
h = int(round(float(canvas.get("h"))))
|
|
except (TypeError, ValueError, OverflowError):
|
|
return None
|
|
if w <= 0 or h <= 0:
|
|
return None
|
|
inputs = state.get("inputs")
|
|
if (
|
|
not isinstance(inputs, list)
|
|
or len(inputs) != len(layers)
|
|
or not all(isinstance(entry, str) for entry in inputs)
|
|
):
|
|
inputs = None
|
|
return {
|
|
"canvas": (w, h),
|
|
"layers": layers,
|
|
"inputs": inputs,
|
|
"background": _parse_background(state.get("background")),
|
|
"order": _parse_order(state.get("order"), len(layers)),
|
|
}
|
|
|
|
|
|
def _number(source: dict, key: str, default: float) -> float:
|
|
value = source.get(key, default)
|
|
if not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
return float(default)
|
|
return float(value)
|
|
|
|
|
|
def _clamped_size(value: float, natural: int) -> float:
|
|
return float(natural) if value <= 0 else min(value, float(MAX_RESOLUTION))
|
|
|
|
|
|
def _layer_params(entry, natural_w: int, natural_h: int) -> dict:
|
|
if not isinstance(entry, dict):
|
|
entry = {}
|
|
transform = entry.get("transform")
|
|
if not isinstance(transform, dict):
|
|
transform = {}
|
|
blend = entry.get("blend")
|
|
return {
|
|
"visible": bool(entry.get("visible", True)),
|
|
# The layer state is untrusted input: it round-trips through the saved
|
|
# workflow and can be posted directly to /prompt. An out-of-range opacity
|
|
# would otherwise reach blend_composite as a raw coverage multiplier and
|
|
# produce negative or greater-than-white RGB. _parse_background already
|
|
# clamps the same field.
|
|
"opacity": min(max(_number(entry, "opacity", 1.0), 0.0), 1.0),
|
|
"blend": blend if isinstance(blend, str) else "normal",
|
|
"x": min(max(_number(transform, "x", 0.0), -MAX_RESOLUTION), MAX_RESOLUTION),
|
|
"y": min(max(_number(transform, "y", 0.0), -MAX_RESOLUTION), MAX_RESOLUTION),
|
|
"w": _clamped_size(_number(transform, "w", natural_w), natural_w),
|
|
"h": _clamped_size(_number(transform, "h", natural_h), natural_h),
|
|
"rotation": _number(transform, "rotation", 0.0),
|
|
"flip_h": bool(entry.get("flipH", False)),
|
|
"flip_v": bool(entry.get("flipV", False)),
|
|
}
|
|
|
|
|
|
def _prepare_layer_bitmap(
|
|
tensor: torch.Tensor, params: dict, alpha: torch.Tensor | None
|
|
) -> Image.Image:
|
|
frame = tensor[0, :, :, :3].detach().cpu().numpy()
|
|
rgb8 = np.clip(np.rint(frame * 255.0), 0, 255).astype(np.uint8)
|
|
if alpha is None:
|
|
img = Image.fromarray(rgb8, "RGB").convert("RGBA")
|
|
else:
|
|
alpha8 = np.clip(
|
|
np.rint(alpha[0].detach().cpu().numpy() * 255.0), 0, 255
|
|
).astype(np.uint8)
|
|
img = Image.fromarray(np.dstack([rgb8, alpha8]), "RGBA")
|
|
if params["flip_h"]:
|
|
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
if params["flip_v"]:
|
|
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
target = (max(1, round(params["w"])), max(1, round(params["h"])))
|
|
if img.size != target:
|
|
img = img.resize(target, Image.Resampling.LANCZOS)
|
|
if params["rotation"] != 0:
|
|
img = img.rotate(
|
|
-math.degrees(params["rotation"]),
|
|
expand=True,
|
|
resample=Image.Resampling.BICUBIC,
|
|
fillcolor=(0, 0, 0, 0),
|
|
)
|
|
return img
|
|
|
|
|
|
def _place_in_bounds(img: Image.Image, bw: int, bh: int) -> np.ndarray:
|
|
arr = np.asarray(img, dtype=np.float32) / 255.0
|
|
rgba = np.concatenate([srgb_to_linear(arr[..., :3]), arr[..., 3:4]], axis=-1)
|
|
aw, ah = img.size
|
|
buf = np.zeros((bh, bw, 4), dtype=np.float32)
|
|
ox = (bw - aw) // 2
|
|
oy = (bh - ah) // 2
|
|
dx0, dy0 = max(ox, 0), max(oy, 0)
|
|
dx1, dy1 = min(ox + aw, bw), min(oy + ah, bh)
|
|
if dx0 < dx1 and dy0 < dy1:
|
|
buf[dy0:dy1, dx0:dx1] = rgba[dy0 - oy : dy1 - oy, dx0 - ox : dx1 - ox]
|
|
return buf
|
|
|
|
|
|
def _fill_background(canvas: np.ndarray, background: dict) -> np.ndarray:
|
|
layer = np.empty(canvas.shape, dtype=np.float32)
|
|
layer[..., :3] = srgb_to_linear(
|
|
np.array(hex_to_rgb(background["color"]), dtype=np.float32) / 255.0
|
|
)
|
|
layer[..., 3] = 1.0
|
|
return blend_composite(
|
|
resolve_mode("normal"), canvas, layer, background["opacity"]
|
|
)
|
|
|
|
|
|
def composite_from_state(
|
|
tensors: list[torch.Tensor],
|
|
state: dict,
|
|
alphas: list[torch.Tensor | None],
|
|
) -> torch.Tensor:
|
|
cw, ch = state["canvas"]
|
|
if cw > MAX_RESOLUTION or ch > MAX_RESOLUTION:
|
|
raise ValueError(
|
|
f"Compositor canvas {cw}x{ch} exceeds the maximum supported size of "
|
|
f"{MAX_RESOLUTION}x{MAX_RESOLUTION}"
|
|
)
|
|
canvas = np.zeros((ch, cw, 4), dtype=np.float32)
|
|
background = state.get("background")
|
|
if background is not None and background["visible"] and background["opacity"] > 0:
|
|
canvas = _fill_background(canvas, background)
|
|
layers = state["layers"]
|
|
order = state.get("order") or range(len(tensors))
|
|
for index in order:
|
|
if index < 0 or index >= len(tensors):
|
|
continue
|
|
tensor = tensors[index]
|
|
entry = layers[index] if index < len(layers) else None
|
|
params = _layer_params(entry, tensor.shape[2], tensor.shape[1])
|
|
if not params["visible"]:
|
|
continue
|
|
img = _prepare_layer_bitmap(
|
|
tensor, params, alphas[index] if index < len(alphas) else None
|
|
)
|
|
bx, by, bw, bh = placed_bounds(
|
|
params["x"], params["y"], params["w"], params["h"], params["rotation"]
|
|
)
|
|
buf = _place_in_bounds(img, bw, bh)
|
|
x0, y0 = max(bx, 0), max(by, 0)
|
|
x1, y1 = min(bx + bw, cw), min(by + bh, ch)
|
|
if x0 >= x1 or y0 >= y1:
|
|
continue
|
|
region = buf[y0 - by : y1 - by, x0 - bx : x1 - bx]
|
|
mode = resolve_mode(params["blend"])
|
|
canvas[y0:y1, x0:x1] = blend_composite(
|
|
mode, canvas[y0:y1, x0:x1], region, params["opacity"]
|
|
)
|
|
rgb = linear_to_srgb(np.clip(canvas[..., :3], 0.0, 1.0))
|
|
alpha = np.clip(canvas[..., 3:4], 0.0, 1.0)
|
|
rgba = np.concatenate([rgb, alpha], axis=-1)
|
|
return torch.from_numpy(rgba.astype(np.float32)).unsqueeze(0)
|
|
|
|
|
|
OPAQUE_EPSILON = 1e-3
|
|
|
|
|
|
def composite_outputs(out: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
if out.shape[-1] != 4:
|
|
return out, torch.zeros(out.shape[:3], dtype=torch.float32)
|
|
alpha = out[..., 3]
|
|
if bool((alpha >= 1.0 - OPAQUE_EPSILON).all()):
|
|
return out[..., :3], torch.zeros_like(alpha)
|
|
return out, torch.clamp(1.0 - alpha, 0.0, 1.0)
|
|
|
|
|
|
class ImageCompositor(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return io.Schema(
|
|
node_id="ImageCompositor",
|
|
display_name="Create Layered Image",
|
|
category="image",
|
|
is_experimental=True,
|
|
is_output_node=True,
|
|
has_intermediate_output=True,
|
|
inputs=[
|
|
io.Layers.Input(
|
|
"layers",
|
|
tooltip="Layer stack to composite; build it with Add Layer. Items are stacked by z_index, batch frames inside an item expand to consecutive layers, and item placement, opacity, and blend mode define the initial composition. Without an explicit document canvas the size is a best-effort maximum extent of the placed layers. A saved composition that matches the current inputs takes priority.",
|
|
),
|
|
io.Compositor.Input(
|
|
"compositor",
|
|
tooltip="Layered composition saved by the compositor editor.",
|
|
),
|
|
],
|
|
outputs=[
|
|
io.Image.Output(
|
|
tooltip="Composited image. Carries an alpha channel when the composite has transparent areas (e.g. hidden background), otherwise plain RGB."
|
|
),
|
|
io.Mask.Output(
|
|
tooltip="Transparency of the composite (1 = fully transparent). All zeros when the composite is opaque."
|
|
),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, layers: io.Layers.Type, compositor: io.Compositor.Type = None) -> io.NodeOutput:
|
|
frames = expand_item_frames(document_items(layers))
|
|
tensors = [frame["tensor"] for frame in frames]
|
|
alphas = [frame_alpha(frame["tensor"], frame["mask"]) for frame in frames]
|
|
|
|
layer_refs = []
|
|
for tensor, alpha in zip(tensors, alphas):
|
|
layer_refs.extend(
|
|
UI.PreviewImage(layer_preview_tensor(tensor, alpha), cls=cls).values
|
|
)
|
|
|
|
fp = input_fingerprints(frames, alphas)
|
|
raw_state = compositor
|
|
state = parse_layer_state(raw_state)
|
|
replay = bool(state is not None and tensors and state["inputs"] == fp)
|
|
if replay:
|
|
out = composite_from_state(tensors, state, alphas)
|
|
elif tensors:
|
|
canvas = document_canvas(layers) or canvas_extent(frames)
|
|
out = composite_from_state(
|
|
tensors, state_from_items(frames, canvas), alphas
|
|
)
|
|
else:
|
|
out = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
|
state_stale = layer_state_provided(raw_state) and not replay
|
|
out, mask = composite_outputs(out)
|
|
|
|
ui_dict = UI.PreviewImage(out, cls=cls).as_dict()
|
|
ui_dict["compositor_layers"] = layer_refs
|
|
ui_dict["compositor_inputs"] = fp
|
|
ui_dict["compositor_bboxes"] = layer_ui_entries(frames)
|
|
if state_stale:
|
|
ui_dict["compositor_state_stale"] = [True]
|
|
return io.NodeOutput(out, mask, ui=ui_dict)
|
|
|
|
|
|
class AddLayer(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return io.Schema(
|
|
node_id="AddLayer",
|
|
display_name="Add Layer",
|
|
category="image",
|
|
is_experimental=True,
|
|
inputs=[
|
|
io.Layers.Input(
|
|
"layers",
|
|
optional=True,
|
|
tooltip="Layer stack to append to. Leave unconnected to start a new stack.",
|
|
),
|
|
io.Image.Input(
|
|
"image",
|
|
tooltip="Layer content at its native size. A batch expands to consecutive layers.",
|
|
),
|
|
io.Mask.Input(
|
|
"mask",
|
|
optional=True,
|
|
tooltip="Transparency mask for this layer. Masked areas (value 1) become transparent, multiplying with any alpha channel the image already carries.",
|
|
),
|
|
io.String.Input(
|
|
"name",
|
|
optional=True,
|
|
default="",
|
|
tooltip="Layer name shown in the compositor editor.",
|
|
),
|
|
io.Int.Input(
|
|
"x",
|
|
optional=True,
|
|
default=0,
|
|
min=-MAX_RESOLUTION,
|
|
max=MAX_RESOLUTION,
|
|
tooltip="Initial horizontal placement on the canvas.",
|
|
),
|
|
io.Int.Input(
|
|
"y",
|
|
optional=True,
|
|
default=0,
|
|
min=-MAX_RESOLUTION,
|
|
max=MAX_RESOLUTION,
|
|
tooltip="Initial vertical placement on the canvas.",
|
|
),
|
|
io.Float.Input(
|
|
"opacity",
|
|
optional=True,
|
|
default=1.0,
|
|
min=0.0,
|
|
max=1.0,
|
|
step=0.01,
|
|
tooltip="Initial layer opacity.",
|
|
),
|
|
io.Combo.Input(
|
|
"blend_mode",
|
|
options=list(_LAYER_MODES),
|
|
default="normal",
|
|
optional=True,
|
|
tooltip="Initial blend mode.",
|
|
),
|
|
io.Float.Input(
|
|
"rotation",
|
|
optional=True,
|
|
default=0.0,
|
|
min=-360.0,
|
|
max=360.0,
|
|
step=1.0,
|
|
tooltip="Initial rotation in degrees, clockwise.",
|
|
),
|
|
io.Int.Input(
|
|
"width",
|
|
optional=True,
|
|
default=0,
|
|
min=0,
|
|
max=MAX_RESOLUTION,
|
|
tooltip="Initial display width. 0 keeps the image's native width.",
|
|
),
|
|
io.Int.Input(
|
|
"height",
|
|
optional=True,
|
|
default=0,
|
|
min=0,
|
|
max=MAX_RESOLUTION,
|
|
tooltip="Initial display height. 0 keeps the image's native height.",
|
|
),
|
|
io.Int.Input(
|
|
"z_index",
|
|
optional=True,
|
|
default=0,
|
|
min=-1000,
|
|
max=1000,
|
|
tooltip="Stacking override. Layers are stable-sorted by z_index; equal values keep their list order.",
|
|
),
|
|
io.Boolean.Input(
|
|
"flip_h",
|
|
optional=True,
|
|
default=False,
|
|
tooltip="Flip the layer horizontally.",
|
|
),
|
|
io.Boolean.Input(
|
|
"flip_v",
|
|
optional=True,
|
|
default=False,
|
|
tooltip="Flip the layer vertically.",
|
|
),
|
|
],
|
|
outputs=[
|
|
io.Layers.Output(tooltip="The layer stack with this layer appended."),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, image: io.Image.Type, layers: io.Layers.Type = None, mask: io.Mask.Type = None, name: str = "", x: int = 0, y: int = 0, opacity: float = 1.0, blend_mode: str = "normal", rotation: float = 0.0, width: int = 0, height: int = 0, z_index: int = 0, flip_h: bool = False, flip_v: bool = False) -> io.NodeOutput:
|
|
item: dict = {
|
|
"image": image,
|
|
"type": "raster",
|
|
"x": int(x),
|
|
"y": int(y),
|
|
"z_index": int(z_index),
|
|
}
|
|
if mask is not None:
|
|
item["mask"] = mask
|
|
if name:
|
|
item["name"] = name
|
|
if opacity != 1.0:
|
|
item["opacity"] = float(opacity)
|
|
if blend_mode != "normal":
|
|
item["blend_mode"] = blend_mode
|
|
if rotation != 0.0:
|
|
item["rotation"] = math.radians(rotation)
|
|
if width > 0:
|
|
item["w"] = int(width)
|
|
if height > 0:
|
|
item["h"] = int(height)
|
|
if flip_h:
|
|
item["flip_h"] = True
|
|
if flip_v:
|
|
item["flip_v"] = True
|
|
previous = layers if isinstance(layers, dict) else None
|
|
document: dict = {
|
|
"version": 1,
|
|
"layers": [*(previous.get("layers") or []), item] if previous else [item],
|
|
}
|
|
previous_canvas = document_canvas(previous)
|
|
if previous_canvas:
|
|
document["canvas"] = previous_canvas
|
|
return io.NodeOutput(document)
|
|
|
|
|
|
class LayersFromBoundingBoxes(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return io.Schema(
|
|
node_id="LayersFromBoundingBoxes",
|
|
display_name="Layers From Bounding Boxes",
|
|
category="image",
|
|
is_experimental=True,
|
|
description=(
|
|
"Turn an image batch plus its bounding boxes into a layer stack, one layer per frame, "
|
|
"each placed by its own box. Use this when a node emits layers as a batch - a batch "
|
|
"carries a single placement for every frame, so the individual positions are otherwise lost."
|
|
),
|
|
inputs=[
|
|
io.Image.Input(
|
|
"image",
|
|
tooltip="Image batch; each frame becomes one layer.",
|
|
),
|
|
io.MultiType.Input(
|
|
"bboxes",
|
|
[io.BoundingBox, io.Array, io.String],
|
|
tooltip=(
|
|
"Placement boxes, index-aligned with the image batch. Accepts bounding boxes "
|
|
"(x, y, width, height), normalized elements (with a 'bbox' - these need "
|
|
"canvas_width/canvas_height to resolve to pixels), or a JSON string of either. "
|
|
"Frames without a matching box are placed at the origin. A box's width/height "
|
|
"scales the layer to fit it. metadata.name (or desc) and metadata.z_index are "
|
|
"used when present, and metadata.content_rect (frame-relative) crops the frame "
|
|
"to its real content."
|
|
),
|
|
),
|
|
io.Mask.Input(
|
|
"mask",
|
|
optional=True,
|
|
tooltip=(
|
|
"Per-frame transparency, index-aligned with the image batch "
|
|
"(1 = transparent, LoadImage convention)."
|
|
),
|
|
),
|
|
io.Layers.Input(
|
|
"layers",
|
|
optional=True,
|
|
tooltip="Layer stack to append to. Leave unconnected to start a new stack.",
|
|
),
|
|
io.Boolean.Input(
|
|
"crop_to_content",
|
|
default=True,
|
|
optional=True,
|
|
tooltip=(
|
|
"Crop each frame to metadata.content_rect where present and place the content "
|
|
"at the box position plus the rect offset. Leave on for batches whose frames "
|
|
"are padded - it keeps only the real content at its true spot."
|
|
),
|
|
),
|
|
io.Int.Input(
|
|
"canvas_width",
|
|
default=0,
|
|
min=0,
|
|
max=MAX_RESOLUTION,
|
|
optional=True,
|
|
tooltip="Document canvas width. 0 derives it from the placed layers.",
|
|
),
|
|
io.Int.Input(
|
|
"canvas_height",
|
|
default=0,
|
|
min=0,
|
|
max=MAX_RESOLUTION,
|
|
optional=True,
|
|
tooltip="Document canvas height. 0 derives it from the placed layers.",
|
|
),
|
|
],
|
|
outputs=[
|
|
io.Layers.Output(tooltip="The layer stack, ready for Create Layered Image."),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(
|
|
cls,
|
|
image: io.Image.Type,
|
|
bboxes: io.MultiType.Type,
|
|
mask: io.Mask.Type = None,
|
|
layers: io.Layers.Type = None,
|
|
crop_to_content: bool = True,
|
|
canvas_width: int = 0,
|
|
canvas_height: int = 0,
|
|
) -> io.NodeOutput:
|
|
boxes = _bbox_list(bboxes, canvas_width, canvas_height)
|
|
previous = layers if isinstance(layers, dict) else None
|
|
items: list[dict] = list((previous.get("layers") or []) if previous else [])
|
|
base_z = max((_int(i.get("z_index"), 0) for i in items), default=-1) + 1
|
|
|
|
for index in range(image.shape[0]):
|
|
box = boxes[index] if index < len(boxes) else {}
|
|
meta = box.get("metadata") if isinstance(box.get("metadata"), dict) else {}
|
|
frame = image[index : index + 1]
|
|
frame_mask = _item_mask_frame(mask, index)
|
|
|
|
x, y = _int(box.get("x"), 0), _int(box.get("y"), 0)
|
|
box_w, box_h = _int(box.get("width"), 0), _int(box.get("height"), 0)
|
|
cropped = False
|
|
rect = meta.get("content_rect")
|
|
if crop_to_content and isinstance(rect, (list, tuple)) and len(rect) == 4:
|
|
left, top, cw, ch = (_int(v, 0) for v in rect)
|
|
left = min(max(left, 0), int(frame.shape[2]))
|
|
top = min(max(top, 0), int(frame.shape[1]))
|
|
cw = min(max(cw, 0), int(frame.shape[2]) - left)
|
|
ch = min(max(ch, 0), int(frame.shape[1]) - top)
|
|
if cw > 0 and ch > 0:
|
|
frame = frame[:, top : top + ch, left : left + cw]
|
|
if frame_mask is not None:
|
|
frame_mask = frame_mask[:, top : top + ch, left : left + cw]
|
|
x, y = x + left, y + top
|
|
cropped = True
|
|
|
|
item: dict = {
|
|
"image": frame,
|
|
"type": "raster",
|
|
"x": x,
|
|
"y": y,
|
|
"z_index": _int(meta.get("z_index"), base_z + index),
|
|
}
|
|
if not cropped:
|
|
if box_w > 0:
|
|
item["w"] = box_w
|
|
if box_h > 0:
|
|
item["h"] = box_h
|
|
if frame_mask is not None:
|
|
item["mask"] = frame_mask
|
|
name = meta.get("name")
|
|
if not (isinstance(name, str) and name):
|
|
name = meta.get("desc")
|
|
if isinstance(name, str) and name:
|
|
item["name"] = name
|
|
items.append(item)
|
|
|
|
document: dict = {"version": 1, "layers": items}
|
|
if canvas_width > 0 and canvas_height > 0:
|
|
document["canvas"] = (canvas_width, canvas_height)
|
|
else:
|
|
inherited = document_canvas(previous)
|
|
if inherited:
|
|
document["canvas"] = inherited
|
|
return io.NodeOutput(document)
|
|
|
|
|
|
class CompositorExtension(ComfyExtension):
|
|
@override
|
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
|
return [ImageCompositor, AddLayer, LayersFromBoundingBoxes]
|
|
|
|
|
|
async def comfy_entrypoint() -> CompositorExtension:
|
|
return CompositorExtension()
|