mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-15 22:16:52 +08:00
feat: LAYERS io type with a chainable Add Layer builder
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user