feat: LAYERS io type with a chainable Add Layer builder

This commit is contained in:
Terry Jia
2026-08-06 21:18:52 -04:00
parent d36bc61a3e
commit 1c4953ff9d
3 changed files with 263 additions and 128 deletions

View File

@@ -847,6 +847,31 @@ class Load3DAnimation(Load3D):
...
@comfytype(io_type="LAYERS")
class Layers(ComfyTypeIO):
class LayerItem(TypedDict):
image: torch.Tensor
type: Literal["raster"]
x: NotRequired[int]
y: NotRequired[int]
mask: NotRequired[torch.Tensor]
z_index: int
name: NotRequired[str]
opacity: NotRequired[float]
blend_mode: NotRequired[str]
visible: NotRequired[bool]
flip_h: NotRequired[bool]
flip_v: NotRequired[bool]
color: NotRequired[str]
class Document(TypedDict):
version: int
canvas: NotRequired[tuple[int, int]]
layers: list["Layers.LayerItem"]
Type = Document
@comfytype(io_type="COMPOSITOR")
class Compositor(ComfyTypeIO):
class LayerState(TypedDict):
@@ -2423,6 +2448,7 @@ __all__ = [
"Load3D",
"Load3DAnimation",
"Compositor",
"Layers",
"Photomaker",
"Point",
"FaceAnalysis",

View File

@@ -8,6 +8,7 @@ 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,
@@ -15,13 +16,70 @@ from comfy_extras.compositor_blend import (
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
def expand_batch_frames(tensor: torch.Tensor) -> list[torch.Tensor]:
return [tensor[index : index + 1] for index in range(tensor.shape[0])]
MAX_LAYERS = 50
def document_items(doc) -> list[dict]:
if not isinstance(doc, dict):
return []
items = [
item
for item in (doc.get("layers") or [])
if isinstance(item, dict) and isinstance(item.get("image"), torch.Tensor)
]
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 _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]):
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),
"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(
@@ -40,15 +98,6 @@ def frame_alpha(
return inv if alpha is None else alpha * inv
def frame_alphas(
tensors: list[torch.Tensor], masks: list[torch.Tensor]
) -> list[torch.Tensor | None]:
return [
frame_alpha(tensor, masks[index] if index < len(masks) else None)
for index, tensor in enumerate(tensors)
]
def layer_preview_tensor(
tensor: torch.Tensor, alpha: torch.Tensor | None
) -> torch.Tensor:
@@ -58,10 +107,10 @@ def layer_preview_tensor(
return torch.cat([rgb, alpha.unsqueeze(-1)], dim=-1)
def canvas_size(tensors: list[torch.Tensor]) -> tuple[int, int]:
def canvas_extent(frames: list[dict]) -> tuple[int, int]:
return (
max(tensor.shape[2] for tensor in tensors),
max(tensor.shape[1] for tensor in tensors),
max(frame["x"] + frame["tensor"].shape[2] for frame in frames),
max(frame["y"] + frame["tensor"].shape[1] for frame in frames),
)
@@ -84,99 +133,50 @@ def input_fingerprints(
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:
def state_from_items(frames: list[dict], canvas: tuple[int, int]) -> 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,
}
})
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["tensor"].shape[2],
"h": frame["tensor"].shape[1],
"rotation": 0,
},
})
return {
"canvas": canvas_size(tensors),
"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["tensor"].shape[2]),
"height": int(frame["tensor"].shape[1]),
"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")
@@ -413,20 +413,9 @@ class ImageCompositor(io.ComfyNode):
is_output_node=True,
has_intermediate_output=True,
inputs=[
io.Image.Input(
"image",
tooltip="Layers to composite. Each batch frame becomes a layer; the first frame is the back layer and each following frame is stacked above the previous one. Batch multiple images upstream to composite them.",
),
io.Mask.Input(
"mask",
optional=True,
tooltip="Optional transparency masks, paired with image frames by batch index (the first mask applies to the first frame). Masked areas (value 1) become transparent, multiplying with any alpha channel the image already carries.",
),
io.MultiType.Input(
"bboxes",
[io.BoundingBox, io.Array, io.String],
optional=True,
tooltip="Optional bounding boxes to initialize the layout, index-aligned with the image frames (bboxes[0] places the first frame). Frames without a bounding box keep their natural size at the origin. A saved composition that matches the current set of inputs takes priority.",
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",
@@ -444,10 +433,10 @@ class ImageCompositor(io.ComfyNode):
)
@classmethod
def execute(cls, image: io.Image.Type, mask: io.Mask.Type = None, compositor: io.Compositor.Type = None, bboxes: io.MultiType.Type = None) -> io.NodeOutput:
tensors = expand_batch_frames(image)
mask_frames = expand_batch_frames(mask) if mask is not None else []
alphas = frame_alphas(tensors, mask_frames)
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):
@@ -459,12 +448,12 @@ class ImageCompositor(io.ComfyNode):
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, *canvas_size(tensors)) if tensors else []
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_bboxes(tensors, slots), alphas
tensors, state_from_items(frames, canvas), alphas
)
else:
out = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
@@ -474,16 +463,135 @@ class ImageCompositor(io.ComfyNode):
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))
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.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", 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 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 CompositorExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ImageCompositor]
return [ImageCompositor, AddLayer]
async def comfy_entrypoint() -> CompositorExtension:

View File

@@ -12,7 +12,8 @@ import torch
from comfy_extras.nodes_compositor import (
_layer_params,
composite_from_state,
state_from_bboxes,
expand_item_frames,
state_from_items,
)
@@ -54,17 +55,17 @@ class TestLayerOpacity:
class TestGraphOnlyBackground:
def test_bbox_layout_background_is_hidden(self):
def test_default_layout_background_is_hidden(self):
# A visible white background here would make every graph-only run emit a
# white matte instead of transparency.
state = state_from_bboxes([_solid([1.0, 0.0, 0.0])], [])
frames = expand_item_frames([{"image": _solid([1.0, 0.0, 0.0])}])
state = state_from_items(frames, (4, 4))
assert state["background"]["visible"] is False
def test_uncovered_canvas_stays_transparent(self):
tensors = [_solid([1.0, 0.0, 0.0], w=2, h=2)]
slots = [{"x": 0, "y": 0, "width": 2, "height": 2}]
state = state_from_bboxes(tensors, slots)
state["canvas"] = (4, 4)
frames = expand_item_frames([{"image": tensors[0]}])
state = state_from_items(frames, (4, 4))
out = composite_from_state(tensors, state, [None])[0]
assert out.shape[-1] == 4
assert float(out[0, 0, 3]) == pytest.approx(1.0)