feat: ImageCompositor node with layer-state compositing

This commit is contained in:
Terry Jia
2026-08-05 00:41:35 -04:00
parent 14b05228ce
commit a444ba34b9
4 changed files with 735 additions and 0 deletions

View File

@@ -847,6 +847,24 @@ class Load3DAnimation(Load3D):
...
@comfytype(io_type="COMPOSITOR")
class Compositor(ComfyTypeIO):
class LayerState(TypedDict):
canvas: dict
background: NotRequired[dict]
inputs: NotRequired[list[str]]
layers: list[dict]
Type = LayerState
class Input(WidgetInput):
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
socketless: bool=True, default: dict=None, advanced: bool=None):
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
if default is None:
self.default = {}
@comfytype(io_type="PHOTOMAKER")
class Photomaker(ComfyTypeIO):
Type = Any
@@ -2403,6 +2421,7 @@ __all__ = [
"Load3DModelInfo",
"Load3D",
"Load3DAnimation",
"Compositor",
"Photomaker",
"Point",
"FaceAnalysis",

View File

@@ -0,0 +1,308 @@
import math
from typing import NamedTuple, Optional, Union
import numpy as np
EPSILON = 1e-6
LUM_R = 0.2224884
LUM_G = 0.71690369
LUM_B = 0.06060791
ArrayLike = Union[np.ndarray, float]
def srgb_to_linear(c: ArrayLike) -> np.ndarray:
c = np.asarray(c, dtype=np.float32)
high = ((np.maximum(c, 0.0) + 0.055) / 1.055) ** 2.4
return np.where(c <= 0.04045, c / 12.92, high).astype(np.float32)
def linear_to_srgb(c: ArrayLike) -> np.ndarray:
c = np.asarray(c, dtype=np.float32)
high = 1.055 * np.maximum(c, 0.0) ** (1.0 / 2.4) - 0.055
return np.where(c <= 0.0031308, 12.92 * c, high).astype(np.float32)
def luminance(rgb: np.ndarray) -> np.ndarray:
return rgb[..., 0] * LUM_R + rgb[..., 1] * LUM_G + rgb[..., 2] * LUM_B
def safe_div(a: ArrayLike, b: ArrayLike) -> np.ndarray:
a, b = np.broadcast_arrays(
np.asarray(a, dtype=np.float32), np.asarray(b, dtype=np.float32)
)
out = np.zeros(b.shape, dtype=np.float32)
np.divide(a, b, out=out, where=np.abs(b) >= EPSILON)
return out
CHANNEL_BLEND = {
"normal": lambda i, l: l,
"multiply": lambda i, l: i * l,
"screen": lambda i, l: 1 - (1 - i) * (1 - l),
"overlay": lambda i, l: np.where(i < 0.5, 2 * i * l, 1 - 2 * (1 - l) * (1 - i)),
"darken": lambda i, l: np.minimum(i, l),
"lighten": lambda i, l: np.maximum(i, l),
"color-dodge": lambda i, l: safe_div(i, 1 - l),
"color-burn": lambda i, l: 1 - safe_div(1 - i, l),
"hard-light": lambda i, l: np.where(
l > 0.5,
np.minimum(1 - (1 - i) * (1 - (l - 0.5) * 2), 1),
np.minimum(i * (l * 2), 1),
),
"soft-light": lambda i, l: (1 - i) * (i * l) + i * (1 - (1 - i) * (1 - l)),
"difference": lambda i, l: np.abs(i - l),
"exclusion": lambda i, l: 0.5 - 2 * (i - 0.5) * (l - 0.5),
"linear-dodge": lambda i, l: i + l,
"linear-burn": lambda i, l: i + l - 1,
"vivid-light": lambda i, l: np.where(
l <= 0.5,
np.maximum(1 - safe_div(1 - i, 2 * l), 0),
np.minimum(safe_div(i, 2 * (1 - l)), 1),
),
"pin-light": lambda i, l: np.where(
l > 0.5, np.maximum(i, 2 * (l - 0.5)), np.minimum(i, 2 * l)
),
"linear-light": lambda i, l: i + 2 * l - 1,
"hard-mix": lambda i, l: np.where(i + l < 1, 0.0, 1.0),
"subtract": lambda i, l: np.maximum(i - l, 0),
"divide": lambda i, l: np.clip(i / np.maximum(l, EPSILON), 0, 1),
"grain-extract": lambda i, l: i - l + 0.5,
"grain-merge": lambda i, l: i + l - 0.5,
}
def _blend_hue(i: np.ndarray, l: np.ndarray) -> np.ndarray:
src_min = l.min(axis=-1)
src_max = l.max(axis=-1)
src_delta = src_max - src_min
achromatic = src_delta <= EPSILON
dest_max = i.max(axis=-1)
dest_delta = dest_max - i.min(axis=-1)
dest_s = np.where(dest_max != 0, dest_delta / np.where(dest_max != 0, dest_max, 1), 0)
ratio = np.where(
achromatic, 0, dest_s * dest_max / np.where(achromatic, 1, src_delta)
)
offset = dest_max - src_max * ratio
return np.where(achromatic[..., None], i, l * ratio[..., None] + offset[..., None])
def _blend_saturation(i: np.ndarray, l: np.ndarray) -> np.ndarray:
dest_max = i.max(axis=-1)
dest_delta = dest_max - i.min(axis=-1)
flat = dest_delta <= EPSILON
src_max = l.max(axis=-1)
src_delta = src_max - l.min(axis=-1)
src_s = np.where(src_max != 0, src_delta / np.where(src_max != 0, src_max, 1), 0)
ratio = np.where(flat, 0, src_s * dest_max / np.where(flat, 1, dest_delta))
offset = (1 - ratio) * dest_max
return np.where(
flat[..., None],
np.broadcast_to(dest_max[..., None], i.shape),
i * ratio[..., None] + offset[..., None],
)
def _blend_color(i: np.ndarray, l: np.ndarray) -> np.ndarray:
dest_l = (i.min(axis=-1) + i.max(axis=-1)) / 2
src_l = (l.min(axis=-1) + l.max(axis=-1)) / 2
gray = (np.abs(src_l) <= EPSILON) | (np.abs(1 - src_l) <= EPSILON)
dest_high = dest_l > 0.5
src_high = src_l > 0.5
dl = np.minimum(dest_l, 1 - dest_l)
sl = np.minimum(src_l, 1 - src_l)
ratio = dl / np.where(gray, 1, sl)
offset = np.where(dest_high, 1 - 2 * dl, 0) + np.where(src_high, 2 * dl - ratio, 0)
return np.where(
gray[..., None],
np.broadcast_to(dest_l[..., None], i.shape),
l * ratio[..., None] + offset[..., None],
)
def _blend_luminosity(i: np.ndarray, l: np.ndarray) -> np.ndarray:
ratio = safe_div(luminance(l), luminance(i))
return i * ratio[..., None]
HSL_BLEND = {
"hue": _blend_hue,
"saturation": _blend_saturation,
"color": _blend_color,
"luminosity": _blend_luminosity,
}
def blend_pixel(blend: str, in_rgb: np.ndarray, layer_rgb: np.ndarray) -> np.ndarray:
in_rgb = np.asarray(in_rgb, dtype=np.float32)
layer_rgb = np.asarray(layer_rgb, dtype=np.float32)
hsl = HSL_BLEND.get(blend)
if hsl is not None:
return np.asarray(hsl(in_rgb, layer_rgb), dtype=np.float32)
fn = CHANNEL_BLEND.get(blend, CHANNEL_BLEND["normal"])
return np.asarray(fn(in_rgb, layer_rgb), dtype=np.float32)
def _composite_union(in_c, layer, comp, cov):
in_a = in_c[..., 3]
layer_a = layer[..., 3] * cov
new_a = layer_a + (1 - layer_a) * in_a
ratio = np.where(new_a != 0, layer_a / np.where(new_a != 0, new_a, 1), 0)
blended = (
ratio[..., None]
* (in_a[..., None] * (comp - layer[..., :3]) + layer[..., :3] - in_c[..., :3])
+ in_c[..., :3]
)
keep = (layer_a == 0) | (new_a == 0)
rgb = np.where(
keep[..., None],
in_c[..., :3],
np.where((in_a == 0)[..., None], layer[..., :3], blended),
)
return np.concatenate([rgb, new_a[..., None]], axis=-1)
def _composite_clip_to_backdrop(in_c, layer, comp, cov):
in_a = in_c[..., 3]
layer_a = layer[..., 3] * cov
mixed = comp * layer_a[..., None] + in_c[..., :3] * (1 - layer_a[..., None])
keep = (in_a == 0) | (layer_a == 0)
rgb = np.where(keep[..., None], in_c[..., :3], mixed)
return np.concatenate([rgb, in_a[..., None]], axis=-1)
def _composite_clip_to_layer(in_c, layer, comp, cov):
in_a = in_c[..., 3]
layer_a = layer[..., 3] * cov
mixed = comp * in_a[..., None] + layer[..., :3] * (1 - in_a[..., None])
rgb = np.where(
(layer_a == 0)[..., None],
in_c[..., :3],
np.where((in_a == 0)[..., None], layer[..., :3], mixed),
)
return np.concatenate([rgb, layer_a[..., None]], axis=-1)
def _composite_intersection(in_c, layer, comp, cov):
new_a = in_c[..., 3] * layer[..., 3] * cov
rgb = np.where((new_a == 0)[..., None], in_c[..., :3], comp)
return np.concatenate([rgb, new_a[..., None]], axis=-1)
_COMPOSITE = {
"union": _composite_union,
"clip-to-backdrop": _composite_clip_to_backdrop,
"clip-to-layer": _composite_clip_to_layer,
"intersection": _composite_intersection,
}
def run_composite(mode: str, in_c, layer, comp, cov) -> np.ndarray:
fn = _COMPOSITE.get(mode, _composite_union)
return fn(in_c, layer, comp, cov)
def _to_space(rgb: np.ndarray, space: str) -> np.ndarray:
return rgb if space == "linear" else linear_to_srgb(rgb)
def _from_space(rgb: np.ndarray, space: str) -> np.ndarray:
return rgb if space == "linear" else srgb_to_linear(rgb)
class EffectiveMode(NamedTuple):
blend: str
blend_space: str
composite_space: str
composite: str
_LAYER_MODES = {
"normal": ("linear", "union"),
"multiply": ("linear", "clip-to-backdrop"),
"screen": ("perceptual", "clip-to-backdrop"),
"overlay": ("perceptual", "clip-to-backdrop"),
"darken": ("linear", "clip-to-backdrop"),
"lighten": ("linear", "clip-to-backdrop"),
"color-dodge": ("perceptual", "clip-to-backdrop"),
"color-burn": ("perceptual", "clip-to-backdrop"),
"hard-light": ("perceptual", "clip-to-backdrop"),
"soft-light": ("perceptual", "clip-to-backdrop"),
"difference": ("perceptual", "clip-to-backdrop"),
"exclusion": ("perceptual", "clip-to-backdrop"),
"linear-dodge": ("linear", "clip-to-backdrop"),
"linear-burn": ("perceptual", "clip-to-backdrop"),
"vivid-light": ("perceptual", "clip-to-backdrop"),
"pin-light": ("perceptual", "clip-to-backdrop"),
"linear-light": ("perceptual", "clip-to-backdrop"),
"hard-mix": ("perceptual", "clip-to-backdrop"),
"subtract": ("linear", "clip-to-backdrop"),
"divide": ("linear", "clip-to-backdrop"),
"grain-extract": ("perceptual", "clip-to-backdrop"),
"grain-merge": ("perceptual", "clip-to-backdrop"),
"hue": ("perceptual", "clip-to-backdrop"),
"saturation": ("perceptual", "clip-to-backdrop"),
"color": ("perceptual", "clip-to-backdrop"),
"luminosity": ("linear", "clip-to-backdrop"),
}
def resolve_mode(blend: str = "normal") -> EffectiveMode:
blend_space, composite = _LAYER_MODES.get(blend, _LAYER_MODES["normal"])
return EffectiveMode(
blend=blend,
blend_space=blend_space,
composite_space="linear",
composite=composite,
)
def blend_composite(
mode: EffectiveMode,
backdrop: np.ndarray,
layer: np.ndarray,
opacity: float,
mask: Optional[ArrayLike] = None,
) -> np.ndarray:
backdrop = np.asarray(backdrop, dtype=np.float32)
layer = np.asarray(layer, dtype=np.float32)
cov = opacity * (1.0 if mask is None else mask)
in_b = _to_space(backdrop[..., :3], mode.blend_space)
layer_b = _to_space(layer[..., :3], mode.blend_space)
comp = _from_space(blend_pixel(mode.blend, in_b, layer_b), mode.blend_space)
if mode.composite_space == "linear":
return run_composite(mode.composite, backdrop, layer, comp, cov)
in_c = np.concatenate(
[_to_space(backdrop[..., :3], mode.composite_space), backdrop[..., 3:4]],
axis=-1,
)
layer_c = np.concatenate(
[_to_space(layer[..., :3], mode.composite_space), layer[..., 3:4]], axis=-1
)
comp_c = _to_space(comp, mode.composite_space)
out = run_composite(mode.composite, in_c, layer_c, comp_c, cov)
rgb = _from_space(out[..., :3], mode.composite_space)
return np.concatenate([rgb, out[..., 3:4]], axis=-1)
def placed_bounds(
x: float, y: float, w: float, h: float, rotation: float
) -> tuple[int, int, int, int]:
cx = x + w / 2
cy = y + h / 2
cos = math.cos(rotation)
sin = math.sin(rotation)
hw = w / 2
hh = h / 2
corners = ((-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh))
xs = [cx + dx * cos - dy * sin for dx, dy in corners]
ys = [cy + dx * sin + dy * cos for dx, dy in corners]
bx = math.floor(min(xs))
by = math.floor(min(ys))
bw = max(1, math.ceil(max(xs)) - bx)
bh = max(1, math.ceil(max(ys)) - by)
return bx, by, bw, bh

View File

@@ -0,0 +1,407 @@
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 (
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 typing_extensions import override
def sort_autogrow_images(images: dict) -> list[torch.Tensor]:
images = images or {}
tensors = []
for name in sorted(images, key=lambda n: int(n.rsplit("_", 1)[-1])):
image = images[name]
if image is None:
continue
tensors.append(image)
return tensors
def expand_batch_frames(tensors: list[torch.Tensor]) -> list[torch.Tensor]:
frames = []
for tensor in tensors:
for index in range(tensor.shape[0]):
frames.append(tensor[index : index + 1])
return frames
def stack_images(tensors: list[torch.Tensor]) -> torch.Tensor:
canvas = tensors[0][:1, :, :, :3].clone()
h, w = canvas.shape[1], canvas.shape[2]
for layer in tensors[1:]:
layer = layer[:1, :, :, :3]
lh = min(layer.shape[1], h)
lw = min(layer.shape[2], w)
canvas[:, :lh, :lw, :] = layer[:, :lh, :lw, :]
return canvas
def input_fingerprints(tensors: list[torch.Tensor]) -> list[str]:
fingerprints = []
for tensor in tensors:
frame = tensor[0].detach().cpu().numpy()
frame8 = np.clip(np.rint(frame * 255.0), 0, 255).astype(np.uint8)
digest = hashlib.sha256()
digest.update(repr(tuple(tensor.shape)).encode())
digest.update(frame8.tobytes())
fingerprints.append(digest.hexdigest()[:16])
return fingerprints
def _bbox_entries(bboxes) -> list:
if bboxes is None:
return []
if isinstance(bboxes, str):
text = bboxes.strip()
if not text:
return []
try:
bboxes = json.loads(text)
except (ValueError, TypeError) as exc:
raise ValueError(f"bboxes string input is not valid JSON: {exc}") from exc
if isinstance(bboxes, dict):
return [bboxes]
if not isinstance(bboxes, list):
raise ValueError(
"bboxes input must be bounding boxes, elements, or a JSON string, "
f"got {type(bboxes).__name__}"
)
if bboxes and isinstance(bboxes[0], list):
return bboxes[0]
return bboxes
def layout_bboxes(bboxes, width: int, height: int) -> list:
slots = []
for entry in _bbox_entries(bboxes):
try:
boxes = boxes_from_input(entry, width, height)
except ValueError:
boxes = []
slots.append(boxes[0] if boxes else None)
return slots
def bbox_layer_name(box: dict) -> str | None:
meta = box.get("metadata")
if not isinstance(meta, dict):
return None
for key in ("name", "desc"):
value = meta.get(key)
if isinstance(value, str) and value.strip():
return value
return None
def _bbox_int(box: dict, key: str) -> int:
value = box.get(key, 0)
return int(round(value)) if isinstance(value, (int, float)) else 0
def bbox_ui_entries(slots: list, count: int) -> list:
if not slots:
return []
entries = []
for index in range(count):
box = slots[index] if index < len(slots) else None
if box is None:
entries.append(None)
continue
entries.append({
"x": _bbox_int(box, "x"),
"y": _bbox_int(box, "y"),
"width": _bbox_int(box, "width"),
"height": _bbox_int(box, "height"),
"name": bbox_layer_name(box),
})
return entries
def state_from_bboxes(tensors: list[torch.Tensor], slots: list) -> dict:
layers = []
for index in range(len(tensors)):
box = slots[index] if index < len(slots) else None
if box is None:
layers.append(None)
else:
layers.append({
"transform": {
"x": box.get("x", 0),
"y": box.get("y", 0),
"w": box.get("width", 0),
"h": box.get("height", 0),
"rotation": 0,
}
})
return {
"canvas": (tensors[0].shape[2], tensors[0].shape[1]),
"layers": layers,
"inputs": None,
"background": {"color": "#ffffff", "opacity": 1.0, "visible": True},
}
_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 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
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):
return None
if w <= 0 or h <= 0:
return None
inputs = state.get("inputs")
if not isinstance(inputs, list) 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")),
}
def _number(source: dict, key: str, default: float) -> float:
value = source.get(key, default)
return float(value) if isinstance(value, (int, float)) else float(default)
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)),
"opacity": _number(entry, "opacity", 1.0),
"blend": blend if isinstance(blend, str) else "normal",
"x": _number(transform, "x", 0.0),
"y": _number(transform, "y", 0.0),
"w": _number(transform, "w", natural_w),
"h": _number(transform, "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) -> Image.Image:
frame = tensor[0, :, :, :3].detach().cpu().numpy()
rgb8 = np.clip(np.rint(frame * 255.0), 0, 255).astype(np.uint8)
img = Image.fromarray(rgb8, "RGB").convert("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) -> torch.Tensor:
cw, ch = state["canvas"]
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"]
for index, tensor in enumerate(tensors):
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)
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))
rgb = rgb * np.clip(canvas[..., 3:4], 0.0, 1.0)
return torch.from_numpy(rgb.astype(np.float32)).unsqueeze(0)
class ImageCompositor(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ImageCompositor",
display_name="Image Compositor",
category="image",
is_output_node=True,
has_intermediate_output=True,
inputs=[
io.Autogrow.Input(
"images",
template=io.Autogrow.TemplatePrefix(
io.Image.Input("image"),
prefix="image_",
min=1,
max=50,
),
tooltip="Layers to composite. The first input is the bottom layer; each subsequent input is stacked above the previous one.",
),
io.MultiType.Input(
"bboxes",
[io.BoundingBox, io.Array, io.String],
optional=True,
tooltip="Optional initial layout: bounding boxes, elements, or a JSON string, index-aligned with the image inputs (bboxes[0] places image_0). Inputs without a box keep their natural size at the origin. A saved compositor recipe that matches the current inputs takes priority.",
),
io.Compositor.Input(
"compositor",
tooltip="Layer recipe saved by the compositor editor, replayed over the current inputs",
),
],
outputs=[
io.Image.Output(),
],
)
@classmethod
def execute(cls, images: io.Autogrow.Type = None, compositor: io.Compositor.Type = None, bboxes: io.MultiType.Type = None) -> io.NodeOutput:
tensors = expand_batch_frames(sort_autogrow_images(images))
layer_refs = []
for tensor in tensors:
layer_refs.extend(UI.PreviewImage(tensor, cls=cls).values)
fp = input_fingerprints(tensors)
raw_state = compositor
state = parse_layer_state(raw_state)
replay = bool(state is not None and tensors and state["inputs"] == fp)
slots = (
layout_bboxes(bboxes, tensors[0].shape[2], tensors[0].shape[1])
if tensors
else []
)
if replay:
out = composite_from_state(tensors, state)
elif tensors and any(slot is not None for slot in slots):
out = composite_from_state(tensors, state_from_bboxes(tensors, slots))
elif tensors:
out = stack_images(tensors)
else:
out = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
state_stale = layer_state_provided(raw_state) and not replay
ui_dict = UI.PreviewImage(out, cls=cls).as_dict()
ui_dict["compositor_layers"] = layer_refs
ui_dict["compositor_inputs"] = fp
ui_dict["compositor_bboxes"] = bbox_ui_entries(slots, len(tensors))
if state_stale:
ui_dict["compositor_state_stale"] = [True]
return io.NodeOutput(out, ui=ui_dict)
class CompositorExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ImageCompositor]
async def comfy_entrypoint() -> CompositorExtension:
return CompositorExtension()

View File

@@ -2492,6 +2492,7 @@ async def init_builtin_extra_nodes():
"nodes_math.py",
"nodes_number_convert.py",
"nodes_painter.py",
"nodes_compositor.py",
"nodes_curve.py",
"nodes_bg_removal.py",
"nodes_rtdetr.py",