From 8fadc7b5be50eca0f39735cfbb12aa83f7474100 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Fri, 7 Aug 2026 16:25:37 -0400 Subject: [PATCH] [Partner Nodes] feat: ImageCompositor node with layer-state compositing, layer from bbox and Seedream Layer Separation node (#15317) --- comfy_api/latest/_io.py | 57 + comfy_api_nodes/apis/bytedance.py | 17 + comfy_api_nodes/nodes_bytedance.py | 370 +++ comfy_extras/compositor_blend.py | 331 +++ comfy_extras/nodes_compositor.py | 852 +++++++ nodes.py | 1 + .../compositor_blend_fixture_gen.py | 111 + .../compositor_blend_golden.json | 2242 +++++++++++++++++ .../compositor_blend_test.py | 157 ++ .../comfy_extras_test/compositor_node_test.py | 72 + 10 files changed, 4210 insertions(+) create mode 100644 comfy_extras/compositor_blend.py create mode 100644 comfy_extras/nodes_compositor.py create mode 100644 tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py create mode 100644 tests-unit/comfy_extras_test/compositor_blend_golden.json create mode 100644 tests-unit/comfy_extras_test/compositor_blend_test.py create mode 100644 tests-unit/comfy_extras_test/compositor_node_test.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 58e49d8e2..6d9e08f47 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -847,6 +847,61 @@ class Load3DAnimation(Load3D): ... +@comfytype(io_type="LAYERS") +class Layers(ComfyTypeIO): + BlendMode = Literal[ + "normal", "multiply", "screen", "overlay", "darken", "lighten", + "color-dodge", "color-burn", "hard-light", "soft-light", "difference", + "exclusion", "linear-dodge", "linear-burn", "vivid-light", "pin-light", + "linear-light", "hard-mix", "subtract", "divide", "grain-extract", + "grain-merge", "hue", "saturation", "color", "luminosity", + ] + + 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["Layers.BlendMode"] + visible: NotRequired[bool] + flip_h: NotRequired[bool] + flip_v: NotRequired[bool] + rotation: NotRequired[float] + w: NotRequired[int] + h: NotRequired[int] + + 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): + version: NotRequired[int] + canvas: dict + background: NotRequired[dict] + inputs: NotRequired[list[str]] + order: NotRequired[list[int]] + 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 +2458,8 @@ __all__ = [ "Load3DModelInfo", "Load3D", "Load3DAnimation", + "Compositor", + "Layers", "Photomaker", "Point", "FaceAnalysis", diff --git a/comfy_api_nodes/apis/bytedance.py b/comfy_api_nodes/apis/bytedance.py index b83bb99ce..7ee83e5f3 100644 --- a/comfy_api_nodes/apis/bytedance.py +++ b/comfy_api_nodes/apis/bytedance.py @@ -35,6 +35,23 @@ class Seedream4TaskCreationRequest(BaseModel): optimize_prompt_options: Seedream5OptimizePromptOptions | None = None +class Seedream5LayerOptimizePromptOptions(BaseModel): + mode: Literal["standard", "fast"] = Field(...) + + +class Seedream5LayerSeparationRequest(BaseModel): + model: str = Field(...) + prompt: str | None = Field(None) + image: str = Field(..., description="Single image URL") + size: str = Field("auto") + seed: int = Field(..., ge=0, le=2147483647) + response_format: str = Field("url") + output_format: str = Field("png") + layer_decomposition: bool = Field(True) + watermark: bool = Field(False) + optimize_prompt_options: Seedream5LayerOptimizePromptOptions | None = Field(None) + + class ImageTaskCreationResponse(BaseModel): model: str = Field(...) created: int = Field(..., description="Unix timestamp (in seconds) indicating time when the request was created.") diff --git a/comfy_api_nodes/nodes_bytedance.py b/comfy_api_nodes/nodes_bytedance.py index f321a496b..09fc445d8 100644 --- a/comfy_api_nodes/nodes_bytedance.py +++ b/comfy_api_nodes/nodes_bytedance.py @@ -1,3 +1,4 @@ +import asyncio import base64 import hashlib import logging @@ -34,6 +35,8 @@ from comfy_api_nodes.apis.bytedance import ( SeedanceVirtualLibraryCreateAssetRequest, Seedream4Options, Seedream4TaskCreationRequest, + Seedream5LayerOptimizePromptOptions, + Seedream5LayerSeparationRequest, Seedream5OptimizePromptOptions, TaskAudioContent, TaskAudioContentUrl, @@ -75,6 +78,7 @@ from comfy_api_nodes.util import ( validate_video_dimensions, validate_video_duration, ) +from comfy_api_nodes.util.common_exceptions import ProcessingInterrupted from server import PromptServer BYTEPLUS_IMAGE_ENDPOINT = "/proxy/byteplus/api/v3/images/generations" @@ -96,6 +100,8 @@ SEEDREAM_PRESETS = { "seedream-4-0-250828": RECOMMENDED_PRESETS_SEEDREAM_4_0, } +SEEDREAM_LAYER_SEPARATION_MODEL = "seedream-5-0-pro-260628" + # Long-running tasks endpoints(e.g., video) BYTEPLUS_TASK_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks" BYTEPLUS_TASK_STATUS_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks" # + /{task_id} @@ -1044,6 +1050,369 @@ class ByteDanceSeedreamNodeV2(IO.ComfyNode): return IO.NodeOutput(torch.cat([await download_url_to_image_tensor(i) for i in urls])) +class ByteDanceSeedreamLayerSeparationNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="ByteDanceSeedreamLayerSeparationNode", + display_name="ByteDance Seedream 5.0 Pro Layer Separation", + category="partner/image/ByteDance", + search_aliases=["layer separation", "split layers", "decompose", "cutout", "RGBA layers"], + description=( + "Decompose an image into a background plate plus up to 16 repositionable transparent layers, " + "each with stacking order, bounding box, name and description." + ), + inputs=[ + IO.Image.Input( + "image", + tooltip=( + "The image to separate. Exactly one image, at least 512x512 pixels, aspect ratio " + "between 1:16 and 16:1. Inputs larger than about 4MP are downscaled before upload." + ), + ), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip=( + "How to separate the image. Leave empty to auto-detect and separate all major elements. " + "Describe elements in natural language to control the separation, or target exact regions " + "with left top right bottom tags (0-1000 per-mille coordinates)." + ), + ), + IO.Combo.Input( + "size", + options=["auto", "1K", "1.5K", "2K"], + default="auto", + tooltip="Output resolution level. 'auto' follows the input image size (clamped to the 1K-2K range).", + ), + IO.Int.Input( + "seed", + default=0, + min=0, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + tooltip="Seed to use for generation.", + ), + IO.Combo.Input( + "prompt_optimization", + options=["standard", "fast"], + default="standard", + optional=True, + advanced=True, + tooltip="Prompt-optimization mode: 'standard' gives higher quality, 'fast' shorter generation time.", + ), + IO.Boolean.Input( + "watermark", + default=False, + optional=True, + advanced=True, + tooltip='Whether to add an "AI generated" watermark to the images.', + ), + IO.Boolean.Input( + "crop_layers", + default=False, + optional=True, + label_on="minimal size", + label_off="full canvas", + tooltip=( + "Geometry of the layers/masks batch outputs (layer_stack is unaffected and always " + "tight). Full canvas: each layer on a base-sized canvas at its bounding-box position - " + "recompose directly with ImageCompositeMasked. Minimal size: each layer cropped to its " + "bounding box (padded to the largest layer for batching) - much smaller tensors; " + "rebuild placement with Layers From Bounding Boxes using the bboxes output." + ), + ), + ], + outputs=[ + IO.Image.Output( + display_name="base_image", + tooltip="The base image (background plate) the layers stack onto.", + ), + IO.Mask.Output( + display_name="base_mask", + tooltip=( + "Transparency of the base image (1 = transparent, LoadImage convention); currently " + "always fully opaque." + ), + ), + IO.Image.Output( + display_name="layers", + tooltip=( + "Transparent layers ordered bottom to top. Full canvas mode: placed on a black " + "base-sized canvas at their bounding-box position. Minimal size mode: cropped to " + "their bounding box, anchored top-left, padded to the largest layer." + ), + ), + IO.Mask.Output( + display_name="masks", + tooltip=( + "Per-layer transparency, index-aligned with the layers batch (1 = transparent, " + "LoadImage convention). For ImageCompositeMasked-style compositing, add InvertMask first." + ), + ), + IO.BoundingBox.Output( + display_name="bboxes", + tooltip=( + "One placement box per layer, index-aligned with the layers batch (feed both, plus " + "masks, into Layers From Bounding Boxes to rebuild per-layer placement): {x, y, width, " + "height, metadata: {name, desc, z_index, native_size, content_rect, flags}}. " + "content_rect = [left, top, width, height] is the layer's content region within its " + "own frame; it lands on the canvas at the box position plus that offset." + ), + ), + IO.Layers.Output( + display_name="layer_stack", + tooltip=( + "Ready-to-edit layer document for Create Layered Image: the base plate plus each " + "element as its own named, tight-cropped layer at its true position and stacking " + "order. Connect directly, or extend with Add Layer." + ), + ), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["size"]), + expr=""" + ( + widgets.size in ["1k", "1.5k"] + ? { + "type": "usd", + "usd": 0.032, + "format": { "suffix": " x images/Run", "approximate": true } + } + : { + "type": "range_usd", + "min_usd": 0.032, + "max_usd": 0.064, + "format": { "suffix": " x images/Run", "approximate": true } + } + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + prompt: str = "", + size: str = "auto", + seed: int = 0, + prompt_optimization: str = "standard", + watermark: bool = False, + crop_layers: bool = False, + ) -> IO.NodeOutput: + if get_number_of_images(image) != 1: + raise ValueError("Only a single input image is supported.") + validate_image_aspect_ratio(image, (1, 16), (16, 1), strict=False) + validate_image_dimensions(image, min_width=512, min_height=512) + + request = Seedream5LayerSeparationRequest( + model=SEEDREAM_LAYER_SEPARATION_MODEL, + prompt=prompt.strip() or None, + image=await upload_image_to_comfyapi(cls, image), + size=size, + seed=seed, + watermark=watermark, + optimize_prompt_options=Seedream5LayerOptimizePromptOptions(mode=prompt_optimization), + ) + response = await sync_op( + cls, + ApiEndpoint(path=BYTEPLUS_IMAGE_ENDPOINT, method="POST"), + response_model=ImageTaskCreationResponse, + data=request, + wait_label="Separating layers", + ) + if response.error: + raise RuntimeError( + f"ByteDance request failed. Code: {response.error['code']}, message: {response.error['message']}" + ) + + def z_index_of(d: dict) -> int: + v = d.get("z_index") + if isinstance(v, bool): + return 1_000_000 + if isinstance(v, (int, float)): + return int(v) + if isinstance(v, str): + try: + return int(v.strip()) + except ValueError: + return 1_000_000 + return 1_000_000 + + data = [d for d in (response.data or []) if isinstance(d, dict)] + if not data or "url" not in data[0]: + raise RuntimeError("Unexpected response: no base image returned.") + base_item = data[0] + if base_item.get("bounding_box") is not None: + logging.warning( + "ByteDance layer separation: base item unexpectedly carries a bounding_box; ignoring it." + ) + if z_index_of(base_item) not in (0, 1_000_000): + raise RuntimeError("Unexpected response: the first item is not the base image.") + layer_items = [d for d in data[1:] if "url" in d] + dropped = len(data) - 1 - len(layer_items) + if dropped > 0: + logging.warning( + "ByteDance layer separation: %d of %d returned elements had no 'url' and were dropped.", + dropped, + len(data) - 1, + ) + if not layer_items: + raise RuntimeError("The model returned no layers. Try a different prompt or input image.") + layer_items.sort(key=z_index_of) + + base_image = (await download_url_to_image_tensor(str(base_item["url"])))[..., :3].contiguous() + height, width = base_image.shape[1], base_image.shape[2] + + specs = [] + for item in layer_items: + flags = [] + bbox = item.get("bounding_box") + absolute = bbox.get("absolute") if isinstance(bbox, dict) else None + if ( + isinstance(absolute, (list, tuple)) + and len(absolute) == 4 + and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in absolute) + ): + left, top, right, bottom = (int(round(v)) for v in absolute) + rect_w, rect_h = right - left, bottom - top # exclusive right/bottom + if rect_w > width or rect_h > height: + rect_w, rect_h = min(rect_w, width), min(rect_h, height) + flags.append("bbox_clamped") + if rect_w <= 0 or rect_h <= 0: + flags.append("bbox_degenerate") + else: + flags.append("bbox_missing") + left, top, rect_w, rect_h = 0, 0, width, height + specs.append({"item": item, "flags": flags, "left": left, "top": top, + "rect_w": rect_w, "rect_h": rect_h, "native_size": "", "stack_item": None}) + + if crop_layers: + canvas_w = max((s["rect_w"] for s in specs if "bbox_degenerate" not in s["flags"]), default=1) + canvas_h = max((s["rect_h"] for s in specs if "bbox_degenerate" not in s["flags"]), default=1) + else: + canvas_w, canvas_h = width, height + base_mask = torch.zeros((1, height, width)) + layers = torch.zeros((len(specs), canvas_h, canvas_w, 3)) + # Create Layered Image / LoadImage mask convention: 1 = transparent + masks = torch.ones((len(specs), canvas_h, canvas_w)) + + semaphore = asyncio.Semaphore(4) + + async def fetch_and_place(i: int, spec: dict) -> None: + item, flags = spec["item"], spec["flags"] + left, top, rect_w, rect_h = spec["left"], spec["top"], spec["rect_w"], spec["rect_h"] + async with semaphore: + try: + rgba = (await download_url_to_image_tensor(str(item["url"])))[0] + except ProcessingInterrupted: + raise + except Exception as exc: + raise RuntimeError( + f"Failed to download layer {i + 1} of {len(specs)} (name={item.get('name')!r}): {exc} " + "The generation completed and was billed; the response with all layer URLs " + "is in ComfyUI/temp/api_logs/." + ) from exc + spec["native_size"] = f"{rgba.shape[1]}x{rgba.shape[0]}" + if "bbox_degenerate" in flags: + return + if (rgba.shape[1], rgba.shape[0]) != (rect_w, rect_h): + # premultiply before resizing: interpolating straight alpha bleeds the undefined + # colors of transparent pixels into the anti-aliased edges + rgba = rgba.clone() + rgba[..., :3] *= rgba[..., 3:4] + rgba = ( + torch.nn.functional.interpolate( + rgba.permute(2, 0, 1).unsqueeze(0), + size=(rect_h, rect_w), + mode="bilinear", + antialias=True, + ) + .squeeze(0) + .permute(1, 2, 0) + ) + alpha = rgba[..., 3:4] + rgba = torch.cat([rgba[..., :3] / alpha.clamp(min=1e-6), alpha], dim=-1).clamp(0, 1) + flags.append("resized_to_bbox") + # straight (unpremultiplied) RGB: downstream compositing applies the mask itself + if crop_layers: + layers[i, :rect_h, :rect_w] = rgba[..., :3] + masks[i, :rect_h, :rect_w] = 1.0 - rgba[..., 3] + else: + x0, y0 = max(left, 0), max(top, 0) + x1, y1 = min(left + rect_w, width), min(top + rect_h, height) + if x0 < x1 and y0 < y1: + patch = rgba[y0 - top : y1 - top, x0 - left : x1 - left] + layers[i, y0:y1, x0:x1] = patch[..., :3] + masks[i, y0:y1, x0:x1] = 1.0 - patch[..., 3] + else: + flags.append("bbox_out_of_canvas") + zi = z_index_of(item) + stack_item = { + "image": rgba[..., :3].unsqueeze(0).contiguous(), + "type": "raster", + "x": left, + "y": top, + "z_index": zi if zi != 1_000_000 else i + 1, + "mask": (1.0 - rgba[..., 3]).unsqueeze(0), + } + if isinstance(item.get("name"), str): + stack_item["name"] = item["name"] + spec["stack_item"] = stack_item + + await asyncio.gather(*(fetch_and_place(i, s) for i, s in enumerate(specs))) + + stack_items = [{"image": base_image, "type": "raster", "x": 0, "y": 0, "z_index": 0, "name": "background"}] + boxes = [] + for i, s in enumerate(specs): + abnormal = [f for f in s["flags"] if f != "resized_to_bbox"] + if abnormal: + logging.warning( + "ByteDance layer separation: layer %d (%r) flagged %s.", + i + 1, + s["item"].get("name"), + ", ".join(abnormal), + ) + if s["stack_item"] is not None: + stack_items.append(s["stack_item"]) + zi = z_index_of(s["item"]) + # placement box sized to this layer's tensor so Create Layered Image renders it 1:1; + # the true content rect travels in metadata, frame-relative + rect_x, rect_y = (0, 0) if crop_layers else (s["left"], s["top"]) + boxes.append( + { + "x": s["left"] if crop_layers else 0, + "y": s["top"] if crop_layers else 0, + "width": canvas_w, + "height": canvas_h, + "metadata": { + "name": s["item"].get("name"), + "desc": s["item"].get("description"), + "z_index": zi if zi != 1_000_000 else None, + "native_size": s["native_size"], + "content_rect": [rect_x, rect_y, max(s["rect_w"], 0), max(s["rect_h"], 0)], + "flags": s["flags"], + }, + } + ) + # a single frame holding every box: the per-frame BOUNDING_BOX shape for boxes that + # annotate one image, as emitted and consumed by CreateBoundingBoxes + bboxes = [boxes] + layer_stack = {"version": 1, "canvas": (width, height), "layers": stack_items} + return IO.NodeOutput(base_image, base_mask, layers, masks, bboxes, layer_stack) + + class ByteDanceTextToVideoNode(IO.ComfyNode): @classmethod @@ -3036,6 +3405,7 @@ class ByteDanceExtension(ComfyExtension): ByteDanceImageNode, ByteDanceSeedreamNode, ByteDanceSeedreamNodeV2, + ByteDanceSeedreamLayerSeparationNode, ByteDanceTextToVideoNode, ByteDanceImageToVideoNode, ByteDanceFirstLastFrameNode, diff --git a/comfy_extras/compositor_blend.py b/comfy_extras/compositor_blend.py new file mode 100644 index 000000000..13476e781 --- /dev/null +++ b/comfy_extras/compositor_blend.py @@ -0,0 +1,331 @@ +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: np.where( + i <= 0, + 0.0, + np.where(1 - l <= EPSILON, 1.0, np.minimum(safe_div(i, 1 - l), 1.0)), + ), + "color-burn": lambda i, l: np.where( + i >= 1, + 1.0, + np.where(l <= EPSILON, 0.0, 1 - np.minimum(safe_div(1 - i, l), 1.0)), + ), + "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.where( + i >= 1, + 1.0, + np.where( + 2 * l <= EPSILON, + 0.0, + np.maximum(1 - safe_div(1 - i, 2 * l), 0.0), + ), + ), + np.where( + i <= 0, + 0.0, + np.where( + 2 * (1 - l) <= EPSILON, + 1.0, + np.minimum(safe_div(i, 2 * (1 - l)), 1.0), + ), + ), + ), + "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: + # Scale the backdrop so it carries the layer's luminance. Where the backdrop + # has no luminance to scale there is no hue or saturation to preserve either, + # so the result is a neutral grey at the layer's luminance - which is also the + # analytic limit of i * lum(l)/lum(i) as a grey backdrop approaches black. + # Guarding the numerator here instead (returning black) makes a luminosity + # layer disappear over dark backdrops; see tests-unit/comfy_extras_test/ + # compositor_blend_golden.json. + lum_i = luminance(i) + lum_l = luminance(l) + degenerate = lum_i <= EPSILON + ratio = np.where(degenerate, 0.0, lum_l / np.where(degenerate, 1.0, lum_i)) + return np.where( + degenerate[..., None], + np.broadcast_to(lum_l[..., None], i.shape), + 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: 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=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) + + return run_composite(mode.composite, backdrop, layer, comp, cov) + + +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 diff --git a/comfy_extras/nodes_compositor.py b/comfy_extras/nodes_compositor.py new file mode 100644 index 000000000..10cea3adc --- /dev/null +++ b/comfy_extras/nodes_compositor.py @@ -0,0 +1,852 @@ +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() diff --git a/nodes.py b/nodes.py index 08529d8dc..432f04d89 100644 --- a/nodes.py +++ b/nodes.py @@ -2501,6 +2501,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", diff --git a/tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py b/tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py new file mode 100644 index 000000000..ea7a110e4 --- /dev/null +++ b/tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py @@ -0,0 +1,111 @@ +"""Regenerate ``compositor_blend_golden.json``. + +The golden file is the *shared contract* for layer blending. Every +implementation of these 26 modes must reproduce it within ``tolerance``: + +* ``comfy_extras/compositor_blend.py`` - numpy, server-side compositing +* ``layerBlend.frag`` - GLSL, the live preview in the layer editor +* any future CPU reference in the frontend + +Run from the repository root:: + + python tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py + +and review the diff. A change to this file is a change to user-visible +blending behaviour in every implementation, so it should never be +regenerated just to make a test pass. +""" + +import json +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from comfy_extras.compositor_blend import CHANNEL_BLEND, HSL_BLEND, blend_pixel # noqa: E402 + +GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "compositor_blend_golden.json") + +# Scalar grid for the per-channel modes: both endpoints, the midpoint, values +# just inside each endpoint, and values inside the 1e-6 epsilon guards. +SCALARS = [0.0, 1e-7, 0.001, 0.25, 0.5, 0.75, 0.999, 1.0 - 1e-7, 1.0] + +# Colour pairs for the HSL modes, which read all three channels at once. +COLORS = [ + [0.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + [0.5, 0.5, 0.5], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [0.2, 0.4, 0.6], + [0.9, 0.1, 0.35], + [1e-7, 1e-7, 1e-7], + [1e-7, 0.0, 0.0], + [0.05, 0.05, 0.05], +] + + +def _round(value) -> float: + return round(float(value), 7) + + +def build() -> dict: + channel = {} + for mode in CHANNEL_BLEND: + rows = [] + for i in SCALARS: + for l in SCALARS: + out = blend_pixel(mode, np.float32([i] * 3), np.float32([l] * 3)) + rows.append([_round(i), _round(l), _round(np.asarray(out).reshape(3)[0])]) + channel[mode] = rows + hsl = {} + for mode in HSL_BLEND: + rows = [] + for i in COLORS: + for l in COLORS: + out = blend_pixel(mode, np.float32(i), np.float32(l)) + rows.append([ + [_round(v) for v in i], + [_round(v) for v in l], + [_round(v) for v in np.asarray(out).reshape(3)], + ]) + hsl[mode] = rows + return { + "_comment": ( + "Golden blend values shared by comfy_extras/compositor_blend.py and " + "layerBlend.frag. Inputs are unpremultiplied colours already in the " + "blend space; outputs are unclamped (the compositor clamps once, at " + "the end). 'channel' rows are [i, l, out] applied per channel; 'hsl' " + "rows are [rgb_backdrop, rgb_layer, rgb_out]. Regenerate with " + "tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py." + ), + "tolerance": 1e-4, + "channel": channel, + "hsl": hsl, + } + + +def dumps(data: dict) -> str: + """One row per line, so a behaviour change shows up as a readable diff.""" + lines = ["{", f' "_comment": {json.dumps(data["_comment"])},', f' "tolerance": {data["tolerance"]},'] + for section in ("channel", "hsl"): + lines.append(f' "{section}": {{') + modes = sorted(data[section]) + for m_index, mode in enumerate(modes): + lines.append(f' "{mode}": [') + rows = data[section][mode] + for r_index, row in enumerate(rows): + comma = "" if r_index == len(rows) - 1 else "," + lines.append(f" {json.dumps(row)}{comma}") + lines.append(" ]" + ("" if m_index == len(modes) - 1 else ",")) + lines.append(" }" + ("," if section == "channel" else "")) + lines.append("}") + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + with open(GOLDEN_PATH, "w") as handle: + handle.write(dumps(build())) + sys.stdout.write(f"wrote {GOLDEN_PATH}\n") diff --git a/tests-unit/comfy_extras_test/compositor_blend_golden.json b/tests-unit/comfy_extras_test/compositor_blend_golden.json new file mode 100644 index 000000000..65a06ead7 --- /dev/null +++ b/tests-unit/comfy_extras_test/compositor_blend_golden.json @@ -0,0 +1,2242 @@ +{ + "_comment": "Golden blend values shared by comfy_extras/compositor_blend.py and layerBlend.frag. Inputs are unpremultiplied colours already in the blend space; outputs are unclamped (the compositor clamps once, at the end). 'channel' rows are [i, l, out] applied per channel; 'hsl' rows are [rgb_backdrop, rgb_layer, rgb_out]. Regenerate with tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py.", + "tolerance": 0.0001, + "channel": { + "color-burn": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 0.0], + [1e-07, 0.5, 0.0], + [1e-07, 0.75, 0.0], + [1e-07, 0.999, 0.0], + [1e-07, 0.9999999, 0.0], + [1e-07, 1.0, 1e-07], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 0.0], + [0.001, 0.001, 0.0], + [0.001, 0.25, 0.0], + [0.001, 0.5, 0.0], + [0.001, 0.75, 0.0], + [0.001, 0.999, 0.0], + [0.001, 0.9999999, 0.0009999], + [0.001, 1.0, 0.001], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 0.0], + [0.25, 0.001, 0.0], + [0.25, 0.25, 0.0], + [0.25, 0.5, 0.0], + [0.25, 0.75, 0.0], + [0.25, 0.999, 0.2492493], + [0.25, 0.9999999, 0.2499999], + [0.25, 1.0, 0.25], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 0.0], + [0.5, 0.001, 0.0], + [0.5, 0.25, 0.0], + [0.5, 0.5, 0.0], + [0.5, 0.75, 0.3333333], + [0.5, 0.999, 0.4994995], + [0.5, 0.9999999, 0.4999999], + [0.5, 1.0, 0.5], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 0.0], + [0.75, 0.001, 0.0], + [0.75, 0.25, 0.0], + [0.75, 0.5, 0.5], + [0.75, 0.75, 0.6666666], + [0.75, 0.999, 0.7497498], + [0.75, 0.9999999, 0.75], + [0.75, 1.0, 0.75], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 0.0], + [0.999, 0.001, 1.29e-05], + [0.999, 0.25, 0.9960001], + [0.999, 0.5, 0.998], + [0.999, 0.75, 0.9986667], + [0.999, 0.999, 0.998999], + [0.999, 0.9999999, 0.999], + [0.999, 1.0, 0.999], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 0.0], + [0.9999999, 0.001, 0.9998808], + [0.9999999, 0.25, 0.9999995], + [0.9999999, 0.5, 0.9999998], + [0.9999999, 0.75, 0.9999998], + [0.9999999, 0.999, 0.9999999], + [0.9999999, 0.9999999, 0.9999999], + [0.9999999, 1.0, 0.9999999], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "color-dodge": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 1e-07], + [1e-07, 0.001, 1e-07], + [1e-07, 0.25, 1e-07], + [1e-07, 0.5, 2e-07], + [1e-07, 0.75, 4e-07], + [1e-07, 0.999, 0.0001], + [1e-07, 0.9999999, 1.0], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.001], + [0.001, 0.001, 0.001001], + [0.001, 0.25, 0.0013333], + [0.001, 0.5, 0.002], + [0.001, 0.75, 0.004], + [0.001, 0.999, 1.0], + [0.001, 0.9999999, 1.0], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.25], + [0.25, 0.001, 0.2502503], + [0.25, 0.25, 0.3333333], + [0.25, 0.5, 0.5], + [0.25, 0.75, 1.0], + [0.25, 0.999, 1.0], + [0.25, 0.9999999, 1.0], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.5000001], + [0.5, 0.001, 0.5005005], + [0.5, 0.25, 0.6666667], + [0.5, 0.5, 1.0], + [0.5, 0.75, 1.0], + [0.5, 0.999, 1.0], + [0.5, 0.9999999, 1.0], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.7500001], + [0.75, 0.001, 0.7507507], + [0.75, 0.25, 1.0], + [0.75, 0.5, 1.0], + [0.75, 0.75, 1.0], + [0.75, 0.999, 1.0], + [0.75, 0.9999999, 1.0], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.9990001], + [0.999, 0.001, 1.0], + [0.999, 0.25, 1.0], + [0.999, 0.5, 1.0], + [0.999, 0.75, 1.0], + [0.999, 0.999, 1.0], + [0.999, 0.9999999, 1.0], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 1.0], + [0.9999999, 0.001, 1.0], + [0.9999999, 0.25, 1.0], + [0.9999999, 0.5, 1.0], + [0.9999999, 0.75, 1.0], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "darken": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 1e-07], + [1e-07, 0.001, 1e-07], + [1e-07, 0.25, 1e-07], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 1e-07], + [1e-07, 0.999, 1e-07], + [1e-07, 0.9999999, 1e-07], + [1e-07, 1.0, 1e-07], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 1e-07], + [0.001, 0.001, 0.001], + [0.001, 0.25, 0.001], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.001], + [0.001, 0.999, 0.001], + [0.001, 0.9999999, 0.001], + [0.001, 1.0, 0.001], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 1e-07], + [0.25, 0.001, 0.001], + [0.25, 0.25, 0.25], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.25], + [0.25, 0.999, 0.25], + [0.25, 0.9999999, 0.25], + [0.25, 1.0, 0.25], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 1e-07], + [0.5, 0.001, 0.001], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.5], + [0.5, 0.999, 0.5], + [0.5, 0.9999999, 0.5], + [0.5, 1.0, 0.5], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 1e-07], + [0.75, 0.001, 0.001], + [0.75, 0.25, 0.25], + [0.75, 0.5, 0.5], + [0.75, 0.75, 0.75], + [0.75, 0.999, 0.75], + [0.75, 0.9999999, 0.75], + [0.75, 1.0, 0.75], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 1e-07], + [0.999, 0.001, 0.001], + [0.999, 0.25, 0.25], + [0.999, 0.5, 0.5], + [0.999, 0.75, 0.75], + [0.999, 0.999, 0.999], + [0.999, 0.9999999, 0.999], + [0.999, 1.0, 0.999], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 1e-07], + [0.9999999, 0.001, 0.001], + [0.9999999, 0.25, 0.25], + [0.9999999, 0.5, 0.5], + [0.9999999, 0.75, 0.75], + [0.9999999, 0.999, 0.999], + [0.9999999, 0.9999999, 0.9999999], + [0.9999999, 1.0, 0.9999999], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 1e-07], + [1.0, 0.001, 0.001], + [1.0, 0.25, 0.25], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.75], + [1.0, 0.999, 0.999], + [1.0, 0.9999999, 0.9999999], + [1.0, 1.0, 1.0] + ], + "difference": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 1e-07], + [0.0, 0.001, 0.001], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.5], + [0.0, 0.75, 0.75], + [0.0, 0.999, 0.999], + [0.0, 0.9999999, 0.9999999], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0009999], + [1e-07, 0.25, 0.2499999], + [1e-07, 0.5, 0.4999999], + [1e-07, 0.75, 0.7499999], + [1e-07, 0.999, 0.9989999], + [1e-07, 0.9999999, 0.9999998], + [1e-07, 1.0, 0.9999999], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.0009999], + [0.001, 0.001, 0.0], + [0.001, 0.25, 0.249], + [0.001, 0.5, 0.499], + [0.001, 0.75, 0.749], + [0.001, 0.999, 0.998], + [0.001, 0.9999999, 0.9989999], + [0.001, 1.0, 0.999], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.2499999], + [0.25, 0.001, 0.249], + [0.25, 0.25, 0.0], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.5], + [0.25, 0.999, 0.749], + [0.25, 0.9999999, 0.7499999], + [0.25, 1.0, 0.75], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.4999999], + [0.5, 0.001, 0.499], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.0], + [0.5, 0.75, 0.25], + [0.5, 0.999, 0.499], + [0.5, 0.9999999, 0.4999999], + [0.5, 1.0, 0.5], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.7499999], + [0.75, 0.001, 0.749], + [0.75, 0.25, 0.5], + [0.75, 0.5, 0.25], + [0.75, 0.75, 0.0], + [0.75, 0.999, 0.249], + [0.75, 0.9999999, 0.2499999], + [0.75, 1.0, 0.25], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.9989999], + [0.999, 0.001, 0.998], + [0.999, 0.25, 0.749], + [0.999, 0.5, 0.499], + [0.999, 0.75, 0.249], + [0.999, 0.999, 0.0], + [0.999, 0.9999999, 0.0009999], + [0.999, 1.0, 0.001], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 0.9999998], + [0.9999999, 0.001, 0.9989999], + [0.9999999, 0.25, 0.7499999], + [0.9999999, 0.5, 0.4999999], + [0.9999999, 0.75, 0.2499999], + [0.9999999, 0.999, 0.0009999], + [0.9999999, 0.9999999, 0.0], + [0.9999999, 1.0, 1e-07], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 0.9999999], + [1.0, 0.001, 0.999], + [1.0, 0.25, 0.75], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.25], + [1.0, 0.999, 0.001], + [1.0, 0.9999999, 1e-07], + [1.0, 1.0, 0.0] + ], + "divide": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.1], + [1e-07, 1e-07, 0.1], + [1e-07, 0.001, 0.0001], + [1e-07, 0.25, 4e-07], + [1e-07, 0.5, 2e-07], + [1e-07, 0.75, 1e-07], + [1e-07, 0.999, 1e-07], + [1e-07, 0.9999999, 1e-07], + [1e-07, 1.0, 1e-07], + [0.001, 0.0, 1.0], + [0.001, 1e-07, 1.0], + [0.001, 0.001, 1.0], + [0.001, 0.25, 0.004], + [0.001, 0.5, 0.002], + [0.001, 0.75, 0.0013333], + [0.001, 0.999, 0.001001], + [0.001, 0.9999999, 0.001], + [0.001, 1.0, 0.001], + [0.25, 0.0, 1.0], + [0.25, 1e-07, 1.0], + [0.25, 0.001, 1.0], + [0.25, 0.25, 1.0], + [0.25, 0.5, 0.5], + [0.25, 0.75, 0.3333333], + [0.25, 0.999, 0.2502503], + [0.25, 0.9999999, 0.25], + [0.25, 1.0, 0.25], + [0.5, 0.0, 1.0], + [0.5, 1e-07, 1.0], + [0.5, 0.001, 1.0], + [0.5, 0.25, 1.0], + [0.5, 0.5, 1.0], + [0.5, 0.75, 0.6666667], + [0.5, 0.999, 0.5005005], + [0.5, 0.9999999, 0.5000001], + [0.5, 1.0, 0.5], + [0.75, 0.0, 1.0], + [0.75, 1e-07, 1.0], + [0.75, 0.001, 1.0], + [0.75, 0.25, 1.0], + [0.75, 0.5, 1.0], + [0.75, 0.75, 1.0], + [0.75, 0.999, 0.7507507], + [0.75, 0.9999999, 0.7500001], + [0.75, 1.0, 0.75], + [0.999, 0.0, 1.0], + [0.999, 1e-07, 1.0], + [0.999, 0.001, 1.0], + [0.999, 0.25, 1.0], + [0.999, 0.5, 1.0], + [0.999, 0.75, 1.0], + [0.999, 0.999, 1.0], + [0.999, 0.9999999, 0.9990001], + [0.999, 1.0, 0.999], + [0.9999999, 0.0, 1.0], + [0.9999999, 1e-07, 1.0], + [0.9999999, 0.001, 1.0], + [0.9999999, 0.25, 1.0], + [0.9999999, 0.5, 1.0], + [0.9999999, 0.75, 1.0], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 0.9999999], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "exclusion": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 1e-07], + [0.0, 0.001, 0.001], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.5], + [0.0, 0.75, 0.75], + [0.0, 0.999, 0.999], + [0.0, 0.9999999, 0.9999999], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 2e-07], + [1e-07, 0.001, 0.0010001], + [1e-07, 0.25, 0.2500001], + [1e-07, 0.5, 0.5], + [1e-07, 0.75, 0.7499999], + [1e-07, 0.999, 0.999], + [1e-07, 0.9999999, 0.9999998], + [1e-07, 1.0, 0.9999999], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.0010001], + [0.001, 0.001, 0.001998], + [0.001, 0.25, 0.2505], + [0.001, 0.5, 0.5], + [0.001, 0.75, 0.7495], + [0.001, 0.999, 0.9980021], + [0.001, 0.9999999, 0.9989999], + [0.001, 1.0, 0.999], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.2500001], + [0.25, 0.001, 0.2505], + [0.25, 0.25, 0.375], + [0.25, 0.5, 0.5], + [0.25, 0.75, 0.625], + [0.25, 0.999, 0.7495], + [0.25, 0.9999999, 0.7499999], + [0.25, 1.0, 0.75], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.5], + [0.5, 0.001, 0.5], + [0.5, 0.25, 0.5], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.5], + [0.5, 0.999, 0.5], + [0.5, 0.9999999, 0.5], + [0.5, 1.0, 0.5], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.7499999], + [0.75, 0.001, 0.7495], + [0.75, 0.25, 0.625], + [0.75, 0.5, 0.5], + [0.75, 0.75, 0.375], + [0.75, 0.999, 0.2505], + [0.75, 0.9999999, 0.2500001], + [0.75, 1.0, 0.25], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.999], + [0.999, 0.001, 0.9980021], + [0.999, 0.25, 0.7495], + [0.999, 0.5, 0.5], + [0.999, 0.75, 0.2505], + [0.999, 0.999, 0.001998], + [0.999, 0.9999999, 0.0010001], + [0.999, 1.0, 0.001], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 0.9999998], + [0.9999999, 0.001, 0.9989999], + [0.9999999, 0.25, 0.7499999], + [0.9999999, 0.5, 0.5], + [0.9999999, 0.75, 0.2500001], + [0.9999999, 0.999, 0.0010001], + [0.9999999, 0.9999999, 2e-07], + [0.9999999, 1.0, 1e-07], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 0.9999999], + [1.0, 0.001, 0.999], + [1.0, 0.25, 0.75], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.25], + [1.0, 0.999, 0.001], + [1.0, 0.9999999, 1e-07], + [1.0, 1.0, 0.0] + ], + "grain-extract": [ + [0.0, 0.0, 0.5], + [0.0, 1e-07, 0.4999999], + [0.0, 0.001, 0.499], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.0], + [0.0, 0.75, -0.25], + [0.0, 0.999, -0.499], + [0.0, 0.9999999, -0.4999999], + [0.0, 1.0, -0.5], + [1e-07, 0.0, 0.5000001], + [1e-07, 1e-07, 0.5], + [1e-07, 0.001, 0.4990001], + [1e-07, 0.25, 0.2500001], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, -0.2499999], + [1e-07, 0.999, -0.4989999], + [1e-07, 0.9999999, -0.4999998], + [1e-07, 1.0, -0.4999999], + [0.001, 0.0, 0.501], + [0.001, 1e-07, 0.5009999], + [0.001, 0.001, 0.5], + [0.001, 0.25, 0.251], + [0.001, 0.5, 0.001], + [0.001, 0.75, -0.249], + [0.001, 0.999, -0.498], + [0.001, 0.9999999, -0.4989999], + [0.001, 1.0, -0.499], + [0.25, 0.0, 0.75], + [0.25, 1e-07, 0.7499999], + [0.25, 0.001, 0.749], + [0.25, 0.25, 0.5], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.0], + [0.25, 0.999, -0.249], + [0.25, 0.9999999, -0.2499999], + [0.25, 1.0, -0.25], + [0.5, 0.0, 1.0], + [0.5, 1e-07, 0.9999999], + [0.5, 0.001, 0.999], + [0.5, 0.25, 0.75], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.25], + [0.5, 0.999, 0.001], + [0.5, 0.9999999, 1e-07], + [0.5, 1.0, 0.0], + [0.75, 0.0, 1.25], + [0.75, 1e-07, 1.2499999], + [0.75, 0.001, 1.2490001], + [0.75, 0.25, 1.0], + [0.75, 0.5, 0.75], + [0.75, 0.75, 0.5], + [0.75, 0.999, 0.251], + [0.75, 0.9999999, 0.2500001], + [0.75, 1.0, 0.25], + [0.999, 0.0, 1.4990001], + [0.999, 1e-07, 1.4989998], + [0.999, 0.001, 1.498], + [0.999, 0.25, 1.2490001], + [0.999, 0.5, 0.999], + [0.999, 0.75, 0.749], + [0.999, 0.999, 0.5], + [0.999, 0.9999999, 0.4990001], + [0.999, 1.0, 0.499], + [0.9999999, 0.0, 1.4999999], + [0.9999999, 1e-07, 1.4999998], + [0.9999999, 0.001, 1.4989998], + [0.9999999, 0.25, 1.2499999], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 0.7499999], + [0.9999999, 0.999, 0.5009999], + [0.9999999, 0.9999999, 0.5], + [0.9999999, 1.0, 0.4999999], + [1.0, 0.0, 1.5], + [1.0, 1e-07, 1.4999999], + [1.0, 0.001, 1.4990001], + [1.0, 0.25, 1.25], + [1.0, 0.5, 1.0], + [1.0, 0.75, 0.75], + [1.0, 0.999, 0.501], + [1.0, 0.9999999, 0.5000001], + [1.0, 1.0, 0.5] + ], + "grain-merge": [ + [0.0, 0.0, -0.5], + [0.0, 1e-07, -0.4999999], + [0.0, 0.001, -0.499], + [0.0, 0.25, -0.25], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.25], + [0.0, 0.999, 0.499], + [0.0, 0.9999999, 0.4999999], + [0.0, 1.0, 0.5], + [1e-07, 0.0, -0.4999999], + [1e-07, 1e-07, -0.4999998], + [1e-07, 0.001, -0.4989999], + [1e-07, 0.25, -0.2499999], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 0.2500001], + [1e-07, 0.999, 0.4990001], + [1e-07, 0.9999999, 0.5], + [1e-07, 1.0, 0.5000001], + [0.001, 0.0, -0.499], + [0.001, 1e-07, -0.4989999], + [0.001, 0.001, -0.498], + [0.001, 0.25, -0.249], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.251], + [0.001, 0.999, 0.5], + [0.001, 0.9999999, 0.5009999], + [0.001, 1.0, 0.501], + [0.25, 0.0, -0.25], + [0.25, 1e-07, -0.2499999], + [0.25, 0.001, -0.249], + [0.25, 0.25, 0.0], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.5], + [0.25, 0.999, 0.7490001], + [0.25, 0.9999999, 0.7499999], + [0.25, 1.0, 0.75], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 1e-07], + [0.5, 0.001, 0.001], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.75], + [0.5, 0.999, 0.9990001], + [0.5, 0.9999999, 0.9999999], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.25], + [0.75, 1e-07, 0.2500001], + [0.75, 0.001, 0.251], + [0.75, 0.25, 0.5], + [0.75, 0.5, 0.75], + [0.75, 0.75, 1.0], + [0.75, 0.999, 1.2490001], + [0.75, 0.9999999, 1.2499999], + [0.75, 1.0, 1.25], + [0.999, 0.0, 0.499], + [0.999, 1e-07, 0.4990001], + [0.999, 0.001, 0.5], + [0.999, 0.25, 0.7490001], + [0.999, 0.5, 0.9990001], + [0.999, 0.75, 1.2490001], + [0.999, 0.999, 1.498], + [0.999, 0.9999999, 1.4989998], + [0.999, 1.0, 1.4990001], + [0.9999999, 0.0, 0.4999999], + [0.9999999, 1e-07, 0.5], + [0.9999999, 0.001, 0.5009999], + [0.9999999, 0.25, 0.7499999], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 1.2499999], + [0.9999999, 0.999, 1.4989998], + [0.9999999, 0.9999999, 1.4999998], + [0.9999999, 1.0, 1.4999999], + [1.0, 0.0, 0.5], + [1.0, 1e-07, 0.5000001], + [1.0, 0.001, 0.501], + [1.0, 0.25, 0.75], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.25], + [1.0, 0.999, 1.4990001], + [1.0, 0.9999999, 1.4999999], + [1.0, 1.0, 1.5] + ], + "hard-light": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.5], + [0.0, 0.999, 0.998], + [0.0, 0.9999999, 0.9999998], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 1e-07], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 0.5000001], + [1e-07, 0.999, 0.998], + [1e-07, 0.9999999, 0.9999998], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 0.0], + [0.001, 0.001, 2e-06], + [0.001, 0.25, 0.0005], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.5005], + [0.001, 0.999, 0.9980021], + [0.001, 0.9999999, 0.9999998], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 1e-07], + [0.25, 0.001, 0.0005], + [0.25, 0.25, 0.125], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.625], + [0.25, 0.999, 0.9985], + [0.25, 0.9999999, 0.9999998], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 1e-07], + [0.5, 0.001, 0.001], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.75], + [0.5, 0.999, 0.999], + [0.5, 0.9999999, 0.9999999], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 2e-07], + [0.75, 0.001, 0.0015], + [0.75, 0.25, 0.375], + [0.75, 0.5, 0.75], + [0.75, 0.75, 0.875], + [0.75, 0.999, 0.9995], + [0.75, 0.9999999, 0.9999999], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 2e-07], + [0.999, 0.001, 0.001998], + [0.999, 0.25, 0.4995], + [0.999, 0.5, 0.999], + [0.999, 0.75, 0.9995], + [0.999, 0.999, 0.999998], + [0.999, 0.9999999, 1.0], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 2e-07], + [0.9999999, 0.001, 0.002], + [0.9999999, 0.25, 0.4999999], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 0.9999999], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 2e-07], + [1.0, 0.001, 0.002], + [1.0, 0.25, 0.5], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "hard-mix": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 0.0], + [1e-07, 0.5, 0.0], + [1e-07, 0.75, 0.0], + [1e-07, 0.999, 0.0], + [1e-07, 0.9999999, 1.0], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 0.0], + [0.001, 0.001, 0.0], + [0.001, 0.25, 0.0], + [0.001, 0.5, 0.0], + [0.001, 0.75, 0.0], + [0.001, 0.999, 1.0], + [0.001, 0.9999999, 1.0], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 0.0], + [0.25, 0.001, 0.0], + [0.25, 0.25, 0.0], + [0.25, 0.5, 0.0], + [0.25, 0.75, 1.0], + [0.25, 0.999, 1.0], + [0.25, 0.9999999, 1.0], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 0.0], + [0.5, 0.001, 0.0], + [0.5, 0.25, 0.0], + [0.5, 0.5, 1.0], + [0.5, 0.75, 1.0], + [0.5, 0.999, 1.0], + [0.5, 0.9999999, 1.0], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 0.0], + [0.75, 0.001, 0.0], + [0.75, 0.25, 1.0], + [0.75, 0.5, 1.0], + [0.75, 0.75, 1.0], + [0.75, 0.999, 1.0], + [0.75, 0.9999999, 1.0], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 0.0], + [0.999, 0.001, 1.0], + [0.999, 0.25, 1.0], + [0.999, 0.5, 1.0], + [0.999, 0.75, 1.0], + [0.999, 0.999, 1.0], + [0.999, 0.9999999, 1.0], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 1.0], + [0.9999999, 0.001, 1.0], + [0.9999999, 0.25, 1.0], + [0.9999999, 0.5, 1.0], + [0.9999999, 0.75, 1.0], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "lighten": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 1e-07], + [0.0, 0.001, 0.001], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.5], + [0.0, 0.75, 0.75], + [0.0, 0.999, 0.999], + [0.0, 0.9999999, 0.9999999], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 1e-07], + [1e-07, 0.001, 0.001], + [1e-07, 0.25, 0.25], + [1e-07, 0.5, 0.5], + [1e-07, 0.75, 0.75], + [1e-07, 0.999, 0.999], + [1e-07, 0.9999999, 0.9999999], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.001], + [0.001, 0.001, 0.001], + [0.001, 0.25, 0.25], + [0.001, 0.5, 0.5], + [0.001, 0.75, 0.75], + [0.001, 0.999, 0.999], + [0.001, 0.9999999, 0.9999999], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.25], + [0.25, 0.001, 0.25], + [0.25, 0.25, 0.25], + [0.25, 0.5, 0.5], + [0.25, 0.75, 0.75], + [0.25, 0.999, 0.999], + [0.25, 0.9999999, 0.9999999], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.5], + [0.5, 0.001, 0.5], + [0.5, 0.25, 0.5], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.75], + [0.5, 0.999, 0.999], + [0.5, 0.9999999, 0.9999999], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.75], + [0.75, 0.001, 0.75], + [0.75, 0.25, 0.75], + [0.75, 0.5, 0.75], + [0.75, 0.75, 0.75], + [0.75, 0.999, 0.999], + [0.75, 0.9999999, 0.9999999], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.999], + [0.999, 0.001, 0.999], + [0.999, 0.25, 0.999], + [0.999, 0.5, 0.999], + [0.999, 0.75, 0.999], + [0.999, 0.999, 0.999], + [0.999, 0.9999999, 0.9999999], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 0.9999999], + [0.9999999, 0.001, 0.9999999], + [0.9999999, 0.25, 0.9999999], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 0.9999999], + [0.9999999, 0.999, 0.9999999], + [0.9999999, 0.9999999, 0.9999999], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "linear-burn": [ + [0.0, 0.0, -1.0], + [0.0, 1e-07, -0.9999999], + [0.0, 0.001, -0.999], + [0.0, 0.25, -0.75], + [0.0, 0.5, -0.5], + [0.0, 0.75, -0.25], + [0.0, 0.999, -0.001], + [0.0, 0.9999999, -1e-07], + [0.0, 1.0, 0.0], + [1e-07, 0.0, -0.9999999], + [1e-07, 1e-07, -0.9999998], + [1e-07, 0.001, -0.9989999], + [1e-07, 0.25, -0.7499999], + [1e-07, 0.5, -0.4999999], + [1e-07, 0.75, -0.2499999], + [1e-07, 0.999, -0.0009999], + [1e-07, 0.9999999, 0.0], + [1e-07, 1.0, 1e-07], + [0.001, 0.0, -0.999], + [0.001, 1e-07, -0.9989999], + [0.001, 0.001, -0.998], + [0.001, 0.25, -0.749], + [0.001, 0.5, -0.499], + [0.001, 0.75, -0.249], + [0.001, 0.999, 0.0], + [0.001, 0.9999999, 0.0009999], + [0.001, 1.0, 0.001], + [0.25, 0.0, -0.75], + [0.25, 1e-07, -0.7499999], + [0.25, 0.001, -0.749], + [0.25, 0.25, -0.5], + [0.25, 0.5, -0.25], + [0.25, 0.75, 0.0], + [0.25, 0.999, 0.2490001], + [0.25, 0.9999999, 0.2499999], + [0.25, 1.0, 0.25], + [0.5, 0.0, -0.5], + [0.5, 1e-07, -0.4999999], + [0.5, 0.001, -0.499], + [0.5, 0.25, -0.25], + [0.5, 0.5, 0.0], + [0.5, 0.75, 0.25], + [0.5, 0.999, 0.4990001], + [0.5, 0.9999999, 0.4999999], + [0.5, 1.0, 0.5], + [0.75, 0.0, -0.25], + [0.75, 1e-07, -0.2499999], + [0.75, 0.001, -0.249], + [0.75, 0.25, 0.0], + [0.75, 0.5, 0.25], + [0.75, 0.75, 0.5], + [0.75, 0.999, 0.7490001], + [0.75, 0.9999999, 0.7499999], + [0.75, 1.0, 0.75], + [0.999, 0.0, -0.001], + [0.999, 1e-07, -0.0009999], + [0.999, 0.001, 0.0], + [0.999, 0.25, 0.2490001], + [0.999, 0.5, 0.4990001], + [0.999, 0.75, 0.7490001], + [0.999, 0.999, 0.998], + [0.999, 0.9999999, 0.9989998], + [0.999, 1.0, 0.9990001], + [0.9999999, 0.0, -1e-07], + [0.9999999, 1e-07, 0.0], + [0.9999999, 0.001, 0.0009999], + [0.9999999, 0.25, 0.2499999], + [0.9999999, 0.5, 0.4999999], + [0.9999999, 0.75, 0.7499999], + [0.9999999, 0.999, 0.9989998], + [0.9999999, 0.9999999, 0.9999998], + [0.9999999, 1.0, 0.9999999], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 1e-07], + [1.0, 0.001, 0.001], + [1.0, 0.25, 0.25], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.75], + [1.0, 0.999, 0.9990001], + [1.0, 0.9999999, 0.9999999], + [1.0, 1.0, 1.0] + ], + "linear-dodge": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 1e-07], + [0.0, 0.001, 0.001], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.5], + [0.0, 0.75, 0.75], + [0.0, 0.999, 0.999], + [0.0, 0.9999999, 0.9999999], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 2e-07], + [1e-07, 0.001, 0.0010001], + [1e-07, 0.25, 0.2500001], + [1e-07, 0.5, 0.5000001], + [1e-07, 0.75, 0.7500001], + [1e-07, 0.999, 0.9990001], + [1e-07, 0.9999999, 1.0], + [1e-07, 1.0, 1.0000001], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.0010001], + [0.001, 0.001, 0.002], + [0.001, 0.25, 0.251], + [0.001, 0.5, 0.501], + [0.001, 0.75, 0.751], + [0.001, 0.999, 1.0], + [0.001, 0.9999999, 1.0009999], + [0.001, 1.0, 1.001], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.2500001], + [0.25, 0.001, 0.251], + [0.25, 0.25, 0.5], + [0.25, 0.5, 0.75], + [0.25, 0.75, 1.0], + [0.25, 0.999, 1.2490001], + [0.25, 0.9999999, 1.2499999], + [0.25, 1.0, 1.25], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.5000001], + [0.5, 0.001, 0.501], + [0.5, 0.25, 0.75], + [0.5, 0.5, 1.0], + [0.5, 0.75, 1.25], + [0.5, 0.999, 1.4990001], + [0.5, 0.9999999, 1.4999999], + [0.5, 1.0, 1.5], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.7500001], + [0.75, 0.001, 0.751], + [0.75, 0.25, 1.0], + [0.75, 0.5, 1.25], + [0.75, 0.75, 1.5], + [0.75, 0.999, 1.7490001], + [0.75, 0.9999999, 1.7499999], + [0.75, 1.0, 1.75], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.9990001], + [0.999, 0.001, 1.0], + [0.999, 0.25, 1.2490001], + [0.999, 0.5, 1.4990001], + [0.999, 0.75, 1.7490001], + [0.999, 0.999, 1.998], + [0.999, 0.9999999, 1.9989998], + [0.999, 1.0, 1.9990001], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 1.0], + [0.9999999, 0.001, 1.0009999], + [0.9999999, 0.25, 1.2499999], + [0.9999999, 0.5, 1.4999999], + [0.9999999, 0.75, 1.7499999], + [0.9999999, 0.999, 1.9989998], + [0.9999999, 0.9999999, 1.9999998], + [0.9999999, 1.0, 1.9999999], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0000001], + [1.0, 0.001, 1.001], + [1.0, 0.25, 1.25], + [1.0, 0.5, 1.5], + [1.0, 0.75, 1.75], + [1.0, 0.999, 1.9990001], + [1.0, 0.9999999, 1.9999999], + [1.0, 1.0, 2.0] + ], + "linear-light": [ + [0.0, 0.0, -1.0], + [0.0, 1e-07, -0.9999998], + [0.0, 0.001, -0.998], + [0.0, 0.25, -0.5], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.5], + [0.0, 0.999, 0.998], + [0.0, 0.9999999, 0.9999998], + [0.0, 1.0, 1.0], + [1e-07, 0.0, -0.9999999], + [1e-07, 1e-07, -0.9999997], + [1e-07, 0.001, -0.9979999], + [1e-07, 0.25, -0.4999999], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 0.5000001], + [1e-07, 0.999, 0.9980001], + [1e-07, 0.9999999, 0.9999999], + [1e-07, 1.0, 1.0], + [0.001, 0.0, -0.999], + [0.001, 1e-07, -0.9989998], + [0.001, 0.001, -0.997], + [0.001, 0.25, -0.499], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.501], + [0.001, 0.999, 0.9990001], + [0.001, 0.9999999, 1.0009997], + [0.001, 1.0, 1.0009999], + [0.25, 0.0, -0.75], + [0.25, 1e-07, -0.7499998], + [0.25, 0.001, -0.748], + [0.25, 0.25, -0.25], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.75], + [0.25, 0.999, 1.2480001], + [0.25, 0.9999999, 1.2499998], + [0.25, 1.0, 1.25], + [0.5, 0.0, -0.5], + [0.5, 1e-07, -0.4999998], + [0.5, 0.001, -0.498], + [0.5, 0.25, 0.0], + [0.5, 0.5, 0.5], + [0.5, 0.75, 1.0], + [0.5, 0.999, 1.4980001], + [0.5, 0.9999999, 1.4999998], + [0.5, 1.0, 1.5], + [0.75, 0.0, -0.25], + [0.75, 1e-07, -0.2499998], + [0.75, 0.001, -0.248], + [0.75, 0.25, 0.25], + [0.75, 0.5, 0.75], + [0.75, 0.75, 1.25], + [0.75, 0.999, 1.7480001], + [0.75, 0.9999999, 1.7499998], + [0.75, 1.0, 1.75], + [0.999, 0.0, -0.001], + [0.999, 1e-07, -0.0009998], + [0.999, 0.001, 0.001], + [0.999, 0.25, 0.4990001], + [0.999, 0.5, 0.9990001], + [0.999, 0.75, 1.4990001], + [0.999, 0.999, 1.997], + [0.999, 0.9999999, 1.9989998], + [0.999, 1.0, 1.9990001], + [0.9999999, 0.0, -1e-07], + [0.9999999, 1e-07, 1e-07], + [0.9999999, 0.001, 0.0019999], + [0.9999999, 0.25, 0.4999999], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 1.5], + [0.9999999, 0.999, 1.9979999], + [0.9999999, 0.9999999, 1.9999995], + [0.9999999, 1.0, 2.0], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 2e-07], + [1.0, 0.001, 0.002], + [1.0, 0.25, 0.5], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.5], + [1.0, 0.999, 1.9980001], + [1.0, 0.9999999, 1.9999998], + [1.0, 1.0, 2.0] + ], + "multiply": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 0.0], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 1e-07], + [1e-07, 0.999, 1e-07], + [1e-07, 0.9999999, 1e-07], + [1e-07, 1.0, 1e-07], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 0.0], + [0.001, 0.001, 1e-06], + [0.001, 0.25, 0.00025], + [0.001, 0.5, 0.0005], + [0.001, 0.75, 0.00075], + [0.001, 0.999, 0.000999], + [0.001, 0.9999999, 0.001], + [0.001, 1.0, 0.001], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 0.0], + [0.25, 0.001, 0.00025], + [0.25, 0.25, 0.0625], + [0.25, 0.5, 0.125], + [0.25, 0.75, 0.1875], + [0.25, 0.999, 0.24975], + [0.25, 0.9999999, 0.25], + [0.25, 1.0, 0.25], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 1e-07], + [0.5, 0.001, 0.0005], + [0.5, 0.25, 0.125], + [0.5, 0.5, 0.25], + [0.5, 0.75, 0.375], + [0.5, 0.999, 0.4995], + [0.5, 0.9999999, 0.4999999], + [0.5, 1.0, 0.5], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 1e-07], + [0.75, 0.001, 0.00075], + [0.75, 0.25, 0.1875], + [0.75, 0.5, 0.375], + [0.75, 0.75, 0.5625], + [0.75, 0.999, 0.74925], + [0.75, 0.9999999, 0.7499999], + [0.75, 1.0, 0.75], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 1e-07], + [0.999, 0.001, 0.000999], + [0.999, 0.25, 0.24975], + [0.999, 0.5, 0.4995], + [0.999, 0.75, 0.74925], + [0.999, 0.999, 0.998001], + [0.999, 0.9999999, 0.9989999], + [0.999, 1.0, 0.999], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 1e-07], + [0.9999999, 0.001, 0.001], + [0.9999999, 0.25, 0.25], + [0.9999999, 0.5, 0.4999999], + [0.9999999, 0.75, 0.7499999], + [0.9999999, 0.999, 0.9989999], + [0.9999999, 0.9999999, 0.9999998], + [0.9999999, 1.0, 0.9999999], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 1e-07], + [1.0, 0.001, 0.001], + [1.0, 0.25, 0.25], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.75], + [1.0, 0.999, 0.999], + [1.0, 0.9999999, 0.9999999], + [1.0, 1.0, 1.0] + ], + "normal": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 1e-07], + [0.0, 0.001, 0.001], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.5], + [0.0, 0.75, 0.75], + [0.0, 0.999, 0.999], + [0.0, 0.9999999, 0.9999999], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 1e-07], + [1e-07, 0.001, 0.001], + [1e-07, 0.25, 0.25], + [1e-07, 0.5, 0.5], + [1e-07, 0.75, 0.75], + [1e-07, 0.999, 0.999], + [1e-07, 0.9999999, 0.9999999], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 1e-07], + [0.001, 0.001, 0.001], + [0.001, 0.25, 0.25], + [0.001, 0.5, 0.5], + [0.001, 0.75, 0.75], + [0.001, 0.999, 0.999], + [0.001, 0.9999999, 0.9999999], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 1e-07], + [0.25, 0.001, 0.001], + [0.25, 0.25, 0.25], + [0.25, 0.5, 0.5], + [0.25, 0.75, 0.75], + [0.25, 0.999, 0.999], + [0.25, 0.9999999, 0.9999999], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 1e-07], + [0.5, 0.001, 0.001], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.75], + [0.5, 0.999, 0.999], + [0.5, 0.9999999, 0.9999999], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 1e-07], + [0.75, 0.001, 0.001], + [0.75, 0.25, 0.25], + [0.75, 0.5, 0.5], + [0.75, 0.75, 0.75], + [0.75, 0.999, 0.999], + [0.75, 0.9999999, 0.9999999], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 1e-07], + [0.999, 0.001, 0.001], + [0.999, 0.25, 0.25], + [0.999, 0.5, 0.5], + [0.999, 0.75, 0.75], + [0.999, 0.999, 0.999], + [0.999, 0.9999999, 0.9999999], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 1e-07], + [0.9999999, 0.001, 0.001], + [0.9999999, 0.25, 0.25], + [0.9999999, 0.5, 0.5], + [0.9999999, 0.75, 0.75], + [0.9999999, 0.999, 0.999], + [0.9999999, 0.9999999, 0.9999999], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 1e-07], + [1.0, 0.001, 0.001], + [1.0, 0.25, 0.25], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.75], + [1.0, 0.999, 0.999], + [1.0, 0.9999999, 0.9999999], + [1.0, 1.0, 1.0] + ], + "overlay": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 1e-07], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 2e-07], + [1e-07, 0.999, 2e-07], + [1e-07, 0.9999999, 2e-07], + [1e-07, 1.0, 2e-07], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 0.0], + [0.001, 0.001, 2e-06], + [0.001, 0.25, 0.0005], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.0015], + [0.001, 0.999, 0.001998], + [0.001, 0.9999999, 0.002], + [0.001, 1.0, 0.002], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 1e-07], + [0.25, 0.001, 0.0005], + [0.25, 0.25, 0.125], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.375], + [0.25, 0.999, 0.4995], + [0.25, 0.9999999, 0.4999999], + [0.25, 1.0, 0.5], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 1e-07], + [0.5, 0.001, 0.001], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.75], + [0.5, 0.999, 0.999], + [0.5, 0.9999999, 0.9999999], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.5], + [0.75, 1e-07, 0.5000001], + [0.75, 0.001, 0.5005], + [0.75, 0.25, 0.625], + [0.75, 0.5, 0.75], + [0.75, 0.75, 0.875], + [0.75, 0.999, 0.9995], + [0.75, 0.9999999, 0.9999999], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.998], + [0.999, 1e-07, 0.998], + [0.999, 0.001, 0.9980021], + [0.999, 0.25, 0.9985], + [0.999, 0.5, 0.999], + [0.999, 0.75, 0.9995], + [0.999, 0.999, 0.999998], + [0.999, 0.9999999, 1.0], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.9999998], + [0.9999999, 1e-07, 0.9999998], + [0.9999999, 0.001, 0.9999998], + [0.9999999, 0.25, 0.9999998], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 0.9999999], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "pin-light": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.5], + [0.0, 0.999, 0.998], + [0.0, 0.9999999, 0.9999998], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 1e-07], + [1e-07, 0.001, 1e-07], + [1e-07, 0.25, 1e-07], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 0.5], + [1e-07, 0.999, 0.998], + [1e-07, 0.9999999, 0.9999998], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 2e-07], + [0.001, 0.001, 0.001], + [0.001, 0.25, 0.001], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.5], + [0.001, 0.999, 0.998], + [0.001, 0.9999999, 0.9999998], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 2e-07], + [0.25, 0.001, 0.002], + [0.25, 0.25, 0.25], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.5], + [0.25, 0.999, 0.998], + [0.25, 0.9999999, 0.9999998], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 2e-07], + [0.5, 0.001, 0.002], + [0.5, 0.25, 0.5], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.5], + [0.5, 0.999, 0.998], + [0.5, 0.9999999, 0.9999998], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 2e-07], + [0.75, 0.001, 0.002], + [0.75, 0.25, 0.5], + [0.75, 0.5, 0.75], + [0.75, 0.75, 0.75], + [0.75, 0.999, 0.998], + [0.75, 0.9999999, 0.9999998], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 2e-07], + [0.999, 0.001, 0.002], + [0.999, 0.25, 0.5], + [0.999, 0.5, 0.999], + [0.999, 0.75, 0.999], + [0.999, 0.999, 0.999], + [0.999, 0.9999999, 0.9999998], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 2e-07], + [0.9999999, 0.001, 0.002], + [0.9999999, 0.25, 0.5], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 0.9999999], + [0.9999999, 0.999, 0.9999999], + [0.9999999, 0.9999999, 0.9999999], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 0.0], + [1.0, 1e-07, 2e-07], + [1.0, 0.001, 0.002], + [1.0, 0.25, 0.5], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "screen": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 1e-07], + [0.0, 0.001, 0.001], + [0.0, 0.25, 0.25], + [0.0, 0.5, 0.5], + [0.0, 0.75, 0.75], + [0.0, 0.999, 0.999], + [0.0, 0.9999999, 0.9999999], + [0.0, 1.0, 1.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 2e-07], + [1e-07, 0.001, 0.0010001], + [1e-07, 0.25, 0.2500001], + [1e-07, 0.5, 0.5000001], + [1e-07, 0.75, 0.75], + [1e-07, 0.999, 0.999], + [1e-07, 0.9999999, 0.9999999], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.0010001], + [0.001, 0.001, 0.001999], + [0.001, 0.25, 0.25075], + [0.001, 0.5, 0.5005], + [0.001, 0.75, 0.75025], + [0.001, 0.999, 0.999001], + [0.001, 0.9999999, 0.9999999], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.2500001], + [0.25, 0.001, 0.25075], + [0.25, 0.25, 0.4375], + [0.25, 0.5, 0.625], + [0.25, 0.75, 0.8125], + [0.25, 0.999, 0.99925], + [0.25, 0.9999999, 0.9999999], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.5000001], + [0.5, 0.001, 0.5005], + [0.5, 0.25, 0.625], + [0.5, 0.5, 0.75], + [0.5, 0.75, 0.875], + [0.5, 0.999, 0.9995], + [0.5, 0.9999999, 0.9999999], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.75], + [0.75, 0.001, 0.75025], + [0.75, 0.25, 0.8125], + [0.75, 0.5, 0.875], + [0.75, 0.75, 0.9375], + [0.75, 0.999, 0.99975], + [0.75, 0.9999999, 1.0], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.999], + [0.999, 0.001, 0.999001], + [0.999, 0.25, 0.99925], + [0.999, 0.5, 0.9995], + [0.999, 0.75, 0.99975], + [0.999, 0.999, 0.999999], + [0.999, 0.9999999, 1.0], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 0.9999999], + [0.9999999, 0.001, 0.9999999], + [0.9999999, 0.25, 0.9999999], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 1.0], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "soft-light": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 1e-07], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 2e-07], + [1e-07, 0.999, 2e-07], + [1e-07, 0.9999999, 2e-07], + [1e-07, 1.0, 2e-07], + [0.001, 0.0, 1e-06], + [0.001, 1e-07, 1e-06], + [0.001, 0.001, 3e-06], + [0.001, 0.25, 0.0005005], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.0014995], + [0.001, 0.999, 0.001997], + [0.001, 0.9999999, 0.001999], + [0.001, 1.0, 0.001999], + [0.25, 0.0, 0.0625], + [0.25, 1e-07, 0.0625001], + [0.25, 0.001, 0.062875], + [0.25, 0.25, 0.15625], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.34375], + [0.25, 0.999, 0.437125], + [0.25, 0.9999999, 0.4374999], + [0.25, 1.0, 0.4375], + [0.5, 0.0, 0.25], + [0.5, 1e-07, 0.2500001], + [0.5, 0.001, 0.2505], + [0.5, 0.25, 0.375], + [0.5, 0.5, 0.5], + [0.5, 0.75, 0.625], + [0.5, 0.999, 0.7495], + [0.5, 0.9999999, 0.7499999], + [0.5, 1.0, 0.75], + [0.75, 0.0, 0.5625], + [0.75, 1e-07, 0.5625], + [0.75, 0.001, 0.562875], + [0.75, 0.25, 0.65625], + [0.75, 0.5, 0.75], + [0.75, 0.75, 0.84375], + [0.75, 0.999, 0.937125], + [0.75, 0.9999999, 0.9375], + [0.75, 1.0, 0.9375], + [0.999, 0.0, 0.998001], + [0.999, 1e-07, 0.998001], + [0.999, 0.001, 0.9980031], + [0.999, 0.25, 0.9985005], + [0.999, 0.5, 0.999], + [0.999, 0.75, 0.9994995], + [0.999, 0.999, 0.999997], + [0.999, 0.9999999, 0.999999], + [0.999, 1.0, 0.999999], + [0.9999999, 0.0, 0.9999998], + [0.9999999, 1e-07, 0.9999998], + [0.9999999, 0.001, 0.9999998], + [0.9999999, 0.25, 0.9999998], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 0.9999999], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ], + "subtract": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 1e-07], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 0.0], + [1e-07, 0.5, 0.0], + [1e-07, 0.75, 0.0], + [1e-07, 0.999, 0.0], + [1e-07, 0.9999999, 0.0], + [1e-07, 1.0, 0.0], + [0.001, 0.0, 0.001], + [0.001, 1e-07, 0.0009999], + [0.001, 0.001, 0.0], + [0.001, 0.25, 0.0], + [0.001, 0.5, 0.0], + [0.001, 0.75, 0.0], + [0.001, 0.999, 0.0], + [0.001, 0.9999999, 0.0], + [0.001, 1.0, 0.0], + [0.25, 0.0, 0.25], + [0.25, 1e-07, 0.2499999], + [0.25, 0.001, 0.249], + [0.25, 0.25, 0.0], + [0.25, 0.5, 0.0], + [0.25, 0.75, 0.0], + [0.25, 0.999, 0.0], + [0.25, 0.9999999, 0.0], + [0.25, 1.0, 0.0], + [0.5, 0.0, 0.5], + [0.5, 1e-07, 0.4999999], + [0.5, 0.001, 0.499], + [0.5, 0.25, 0.25], + [0.5, 0.5, 0.0], + [0.5, 0.75, 0.0], + [0.5, 0.999, 0.0], + [0.5, 0.9999999, 0.0], + [0.5, 1.0, 0.0], + [0.75, 0.0, 0.75], + [0.75, 1e-07, 0.7499999], + [0.75, 0.001, 0.749], + [0.75, 0.25, 0.5], + [0.75, 0.5, 0.25], + [0.75, 0.75, 0.0], + [0.75, 0.999, 0.0], + [0.75, 0.9999999, 0.0], + [0.75, 1.0, 0.0], + [0.999, 0.0, 0.999], + [0.999, 1e-07, 0.9989999], + [0.999, 0.001, 0.998], + [0.999, 0.25, 0.749], + [0.999, 0.5, 0.499], + [0.999, 0.75, 0.249], + [0.999, 0.999, 0.0], + [0.999, 0.9999999, 0.0], + [0.999, 1.0, 0.0], + [0.9999999, 0.0, 0.9999999], + [0.9999999, 1e-07, 0.9999998], + [0.9999999, 0.001, 0.9989999], + [0.9999999, 0.25, 0.7499999], + [0.9999999, 0.5, 0.4999999], + [0.9999999, 0.75, 0.2499999], + [0.9999999, 0.999, 0.0009999], + [0.9999999, 0.9999999, 0.0], + [0.9999999, 1.0, 0.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 0.9999999], + [1.0, 0.001, 0.999], + [1.0, 0.25, 0.75], + [1.0, 0.5, 0.5], + [1.0, 0.75, 0.25], + [1.0, 0.999, 0.001], + [1.0, 0.9999999, 1e-07], + [1.0, 1.0, 0.0] + ], + "vivid-light": [ + [0.0, 0.0, 0.0], + [0.0, 1e-07, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.25, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.75, 0.0], + [0.0, 0.999, 0.0], + [0.0, 0.9999999, 0.0], + [0.0, 1.0, 0.0], + [1e-07, 0.0, 0.0], + [1e-07, 1e-07, 0.0], + [1e-07, 0.001, 0.0], + [1e-07, 0.25, 0.0], + [1e-07, 0.5, 1e-07], + [1e-07, 0.75, 2e-07], + [1e-07, 0.999, 5e-05], + [1e-07, 0.9999999, 1.0], + [1e-07, 1.0, 1.0], + [0.001, 0.0, 0.0], + [0.001, 1e-07, 0.0], + [0.001, 0.001, 0.0], + [0.001, 0.25, 0.0], + [0.001, 0.5, 0.001], + [0.001, 0.75, 0.002], + [0.001, 0.999, 0.5000064], + [0.001, 0.9999999, 1.0], + [0.001, 1.0, 1.0], + [0.25, 0.0, 0.0], + [0.25, 1e-07, 0.0], + [0.25, 0.001, 0.0], + [0.25, 0.25, 0.0], + [0.25, 0.5, 0.25], + [0.25, 0.75, 0.5], + [0.25, 0.999, 1.0], + [0.25, 0.9999999, 1.0], + [0.25, 1.0, 1.0], + [0.5, 0.0, 0.0], + [0.5, 1e-07, 0.0], + [0.5, 0.001, 0.0], + [0.5, 0.25, 0.0], + [0.5, 0.5, 0.5], + [0.5, 0.75, 1.0], + [0.5, 0.999, 1.0], + [0.5, 0.9999999, 1.0], + [0.5, 1.0, 1.0], + [0.75, 0.0, 0.0], + [0.75, 1e-07, 0.0], + [0.75, 0.001, 0.0], + [0.75, 0.25, 0.5], + [0.75, 0.5, 0.75], + [0.75, 0.75, 1.0], + [0.75, 0.999, 1.0], + [0.75, 0.9999999, 1.0], + [0.75, 1.0, 1.0], + [0.999, 0.0, 0.0], + [0.999, 1e-07, 0.0], + [0.999, 0.001, 0.5000064], + [0.999, 0.25, 0.998], + [0.999, 0.5, 0.999], + [0.999, 0.75, 1.0], + [0.999, 0.999, 1.0], + [0.999, 0.9999999, 1.0], + [0.999, 1.0, 1.0], + [0.9999999, 0.0, 0.0], + [0.9999999, 1e-07, 0.0], + [0.9999999, 0.001, 0.9999404], + [0.9999999, 0.25, 0.9999998], + [0.9999999, 0.5, 0.9999999], + [0.9999999, 0.75, 1.0], + [0.9999999, 0.999, 1.0], + [0.9999999, 0.9999999, 1.0], + [0.9999999, 1.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1e-07, 1.0], + [1.0, 0.001, 1.0], + [1.0, 0.25, 1.0], + [1.0, 0.5, 1.0], + [1.0, 0.75, 1.0], + [1.0, 0.999, 1.0], + [1.0, 0.9999999, 1.0], + [1.0, 1.0, 1.0] + ] + }, + "hsl": { + "color": [ + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.2, 0.4, 0.6], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.9, 0.1, 0.35], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.05, 0.05, 0.05], [0.0, 0.0, 0.0]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.2, 0.4, 0.6], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.9, 0.1, 0.35], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1e-07, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.05, 0.05, 0.05], [1.0, 1.0, 1.0]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.5, 0.5, 0.5], [0.2, 0.4, 0.6], [0.25, 0.5, 0.75]], + [[0.5, 0.5, 0.5], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1e-07, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.05, 0.05, 0.05], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[1.0, 0.0, 0.0], [0.2, 0.4, 0.6], [0.25, 0.5, 0.75]], + [[1.0, 0.0, 0.0], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [0.05, 0.05, 0.05], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 1.0], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.2, 0.4, 0.6], [0.25, 0.5, 0.75]], + [[0.0, 0.0, 1.0], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 1.0], [1e-07, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 1.0], [0.05, 0.05, 0.05], [0.5, 0.5, 0.5]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 0.0], [0.4, 0.4, 0.4]], + [[0.2, 0.4, 0.6], [1.0, 1.0, 1.0], [0.4, 0.4, 0.4]], + [[0.2, 0.4, 0.6], [0.5, 0.5, 0.5], [0.4, 0.4, 0.4]], + [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.8, 0.0, 0.0]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 1.0], [0.0, 0.0, 0.8]], + [[0.2, 0.4, 0.6], [0.2, 0.4, 0.6], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [0.9, 0.1, 0.35], [0.72, 0.08, 0.28]], + [[0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07], [0.4, 0.4, 0.4]], + [[0.2, 0.4, 0.6], [1e-07, 0.0, 0.0], [0.4, 0.4, 0.4]], + [[0.2, 0.4, 0.6], [0.05, 0.05, 0.05], [0.4, 0.4, 0.4]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.9, 0.1, 0.35], [1.0, 1.0, 1.0], [0.5, 0.5, 0.5]], + [[0.9, 0.1, 0.35], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.9, 0.1, 0.35], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.9, 0.1, 0.35], [0.2, 0.4, 0.6], [0.25, 0.5, 0.75]], + [[0.9, 0.1, 0.35], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5]], + [[0.9, 0.1, 0.35], [1e-07, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.9, 0.1, 0.35], [0.05, 0.05, 0.05], [0.5, 0.5, 0.5]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1.0, 0.0, 0.0], [2e-07, 0.0, 0.0]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 1.0], [0.0, 0.0, 2e-07]], + [[1e-07, 1e-07, 1e-07], [0.2, 0.4, 0.6], [1e-07, 1e-07, 2e-07]], + [[1e-07, 1e-07, 1e-07], [0.9, 0.1, 0.35], [2e-07, 0.0, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1.0, 0.0, 0.0], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1e-07]], + [[1e-07, 0.0, 0.0], [0.2, 0.4, 0.6], [0.0, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.9, 0.1, 0.35], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1.0, 1.0, 1.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.5, 0.5, 0.5], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1.0, 0.0, 0.0], [0.1, 0.0, 0.0]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 1.0], [0.0, 0.0, 0.1]], + [[0.05, 0.05, 0.05], [0.2, 0.4, 0.6], [0.025, 0.05, 0.075]], + [[0.05, 0.05, 0.05], [0.9, 0.1, 0.35], [0.09, 0.01, 0.035]], + [[0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1e-07, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]] + ], + "hue": [ + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.2, 0.4, 0.6], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.9, 0.1, 0.35], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.05, 0.05, 0.05], [0.0, 0.0, 0.0]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.2, 0.4, 0.6], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.9, 0.1, 0.35], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1e-07, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.05, 0.05, 0.05], [1.0, 1.0, 1.0]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 1.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.2, 0.4, 0.6], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.9, 0.1, 0.35], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1e-07, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.05, 0.05, 0.05], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 1.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[1.0, 0.0, 0.0], [0.2, 0.4, 0.6], [1e-07, 0.5000001, 1.0]], + [[1.0, 0.0, 0.0], [0.9, 0.1, 0.35], [1.0, -1e-07, 0.3124999]], + [[1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.05, 0.05, 0.05], [1.0, 0.0, 0.0]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.5, 0.5, 0.5], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.2, 0.4, 0.6], [1e-07, 0.5000001, 1.0]], + [[0.0, 0.0, 1.0], [0.9, 0.1, 0.35], [1.0, -1e-07, 0.3124999]], + [[0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.05, 0.05, 0.05], [0.0, 0.0, 1.0]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 0.0], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [1.0, 1.0, 1.0], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [0.5, 0.5, 0.5], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.6, 0.2, 0.2]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 1.0], [0.2, 0.2, 0.6]], + [[0.2, 0.4, 0.6], [0.2, 0.4, 0.6], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [0.9, 0.1, 0.35], [0.6, 0.2, 0.325]], + [[0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [1e-07, 0.0, 0.0], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [0.05, 0.05, 0.05], [0.2, 0.4, 0.6]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 0.0], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1.0, 1.0, 1.0], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [0.5, 0.5, 0.5], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1.0, 0.0, 0.0], [0.9, 0.1, 0.1]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 1.0], [0.1, 0.1, 0.9]], + [[0.9, 0.1, 0.35], [0.2, 0.4, 0.6], [0.1, 0.4999999, 0.9]], + [[0.9, 0.1, 0.35], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1e-07, 0.0, 0.0], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [0.05, 0.05, 0.05], [0.9, 0.1, 0.35]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 0.0], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [1.0, 1.0, 1.0], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [0.5, 0.5, 0.5], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [1.0, 0.0, 0.0], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1e-07]], + [[1e-07, 0.0, 0.0], [0.2, 0.4, 0.6], [0.0, 0.0, 1e-07]], + [[1e-07, 0.0, 0.0], [0.9, 0.1, 0.35], [1e-07, -0.0, 0.0]], + [[1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [0.05, 0.05, 0.05], [1e-07, 0.0, 0.0]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1.0, 1.0, 1.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.5, 0.5, 0.5], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1.0, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 1.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.2, 0.4, 0.6], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.9, 0.1, 0.35], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1e-07, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]] + ], + "luminosity": [ + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.2224884, 0.2224884, 0.2224884]], + [[0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0606079, 0.0606079, 0.0606079]], + [[0.0, 0.0, 0.0], [0.2, 0.4, 0.6], [0.3676239, 0.3676239, 0.3676239]], + [[0.0, 0.0, 0.0], [0.9, 0.1, 0.35], [0.2931427, 0.2931427, 0.2931427]], + [[0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[0.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[1.0, 1.0, 1.0], [1.0, 0.0, 0.0], [0.2224884, 0.2224884, 0.2224884]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 1.0], [0.0606079, 0.0606079, 0.0606079]], + [[1.0, 1.0, 1.0], [0.2, 0.4, 0.6], [0.3676239, 0.3676239, 0.3676239]], + [[1.0, 1.0, 1.0], [0.9, 0.1, 0.35], [0.2931427, 0.2931427, 0.2931427]], + [[1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1.0, 1.0, 1.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1.0, 1.0, 1.0], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 0.0, 0.0], [0.2224884, 0.2224884, 0.2224884]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 1.0], [0.0606079, 0.0606079, 0.0606079]], + [[0.5, 0.5, 0.5], [0.2, 0.4, 0.6], [0.3676239, 0.3676239, 0.3676239]], + [[0.5, 0.5, 0.5], [0.9, 0.1, 0.35], [0.2931427, 0.2931427, 0.2931427]], + [[0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[0.5, 0.5, 0.5], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.5, 0.5, 0.5], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 1.0], [4.4946165, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [2.2473083, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.2724093, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.2, 0.4, 0.6], [1.6523285, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.9, 0.1, 0.35], [1.3175639, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [4e-07, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.05, 0.05, 0.05], [0.2247308, 0.0, 0.0]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 16.4994965]], + [[0.0, 0.0, 1.0], [0.5, 0.5, 0.5], [0.0, 0.0, 8.2497482]], + [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 3.6709466]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.2, 0.4, 0.6], [0.0, 0.0, 6.0656095]], + [[0.0, 0.0, 1.0], [0.9, 0.1, 0.35], [0.0, 0.0, 4.8367066]], + [[0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07], [0.0, 0.0, 1.6e-06]], + [[0.0, 0.0, 1.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 4e-07]], + [[0.0, 0.0, 1.0], [0.05, 0.05, 0.05], [0.0, 0.0, 0.8249749]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.2, 0.4, 0.6], [1.0, 1.0, 1.0], [0.5440342, 1.0880685, 1.6321028]], + [[0.2, 0.4, 0.6], [0.5, 0.5, 0.5], [0.2720171, 0.5440342, 0.8160514]], + [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.1210413, 0.2420826, 0.363124]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 1.0], [0.0329728, 0.0659456, 0.0989183]], + [[0.2, 0.4, 0.6], [0.2, 0.4, 0.6], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [0.9, 0.1, 0.35], [0.1594797, 0.3189593, 0.478439]], + [[0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 2e-07]], + [[0.2, 0.4, 0.6], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.2, 0.4, 0.6], [0.05, 0.05, 0.05], [0.0272017, 0.0544034, 0.0816052]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.9, 0.1, 0.35], [1.0, 1.0, 1.0], [3.0701771, 0.3411308, 1.1939578]], + [[0.9, 0.1, 0.35], [0.5, 0.5, 0.5], [1.5350885, 0.1705654, 0.5969789]], + [[0.9, 0.1, 0.35], [1.0, 0.0, 0.0], [0.6830788, 0.0758977, 0.2656418]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 1.0], [0.186077, 0.0206752, 0.0723633]], + [[0.9, 0.1, 0.35], [0.2, 0.4, 0.6], [1.1286706, 0.1254079, 0.4389275]], + [[0.9, 0.1, 0.35], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07], [3e-07, 0.0, 1e-07]], + [[0.9, 0.1, 0.35], [1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0]], + [[0.9, 0.1, 0.35], [0.05, 0.05, 0.05], [0.1535089, 0.0170565, 0.0596979]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[1e-07, 1e-07, 1e-07], [1.0, 0.0, 0.0], [0.2224884, 0.2224884, 0.2224884]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 1.0], [0.0606079, 0.0606079, 0.0606079]], + [[1e-07, 1e-07, 1e-07], [0.2, 0.4, 0.6], [0.3676239, 0.3676239, 0.3676239]], + [[1e-07, 1e-07, 1e-07], [0.9, 0.1, 0.35], [0.2931427, 0.2931427, 0.2931427]], + [[1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1e-07, 0.0, 0.0], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[1e-07, 0.0, 0.0], [1.0, 0.0, 0.0], [0.2224884, 0.2224884, 0.2224884]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0606079, 0.0606079, 0.0606079]], + [[1e-07, 0.0, 0.0], [0.2, 0.4, 0.6], [0.3676239, 0.3676239, 0.3676239]], + [[1e-07, 0.0, 0.0], [0.9, 0.1, 0.35], [0.2931427, 0.2931427, 0.2931427]], + [[1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[1e-07, 0.0, 0.0], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.05, 0.05, 0.05], [1.0, 1.0, 1.0], [0.9999999, 0.9999999, 0.9999999]], + [[0.05, 0.05, 0.05], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.05, 0.05, 0.05], [1.0, 0.0, 0.0], [0.2224884, 0.2224884, 0.2224884]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 1.0], [0.0606079, 0.0606079, 0.0606079]], + [[0.05, 0.05, 0.05], [0.2, 0.4, 0.6], [0.3676239, 0.3676239, 0.3676239]], + [[0.05, 0.05, 0.05], [0.9, 0.1, 0.35], [0.2931426, 0.2931426, 0.2931426]], + [[0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[0.05, 0.05, 0.05], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.05, 0.05, 0.05], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]] + ], + "saturation": [ + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.2, 0.4, 0.6], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.9, 0.1, 0.35], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0], [0.05, 0.05, 0.05], [0.0, 0.0, 0.0]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.2, 0.4, 0.6], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.9, 0.1, 0.35], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [1e-07, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 1.0, 1.0], [0.05, 0.05, 0.05], [1.0, 1.0, 1.0]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.0, 0.0, 1.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.2, 0.4, 0.6], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.9, 0.1, 0.35], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [1e-07, 0.0, 0.0], [0.5, 0.5, 0.5]], + [[0.5, 0.5, 0.5], [0.05, 0.05, 0.05], [0.5, 0.5, 0.5]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.2, 0.4, 0.6], [1.0, 0.3333333, 0.3333333]], + [[1.0, 0.0, 0.0], [0.9, 0.1, 0.35], [1.0, 0.1111112, 0.1111112]], + [[1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0]], + [[1.0, 0.0, 0.0], [1e-07, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [0.05, 0.05, 0.05], [1.0, 1.0, 1.0]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + [[0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[0.0, 0.0, 1.0], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], + [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.2, 0.4, 0.6], [0.3333333, 0.3333333, 1.0]], + [[0.0, 0.0, 1.0], [0.9, 0.1, 0.35], [0.1111112, 0.1111112, 1.0]], + [[0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0]], + [[0.0, 0.0, 1.0], [1e-07, 0.0, 0.0], [0.0, 0.0, 1.0]], + [[0.0, 0.0, 1.0], [0.05, 0.05, 0.05], [1.0, 1.0, 1.0]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 0.0], [0.6, 0.6, 0.6]], + [[0.2, 0.4, 0.6], [1.0, 1.0, 1.0], [0.6, 0.6, 0.6]], + [[0.2, 0.4, 0.6], [0.5, 0.5, 0.5], [0.6, 0.6, 0.6]], + [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.3, 0.6]], + [[0.2, 0.4, 0.6], [0.0, 0.0, 1.0], [0.0, 0.3, 0.6]], + [[0.2, 0.4, 0.6], [0.2, 0.4, 0.6], [0.2, 0.4, 0.6]], + [[0.2, 0.4, 0.6], [0.9, 0.1, 0.35], [0.0666667, 0.3333334, 0.6]], + [[0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07], [0.6, 0.6, 0.6]], + [[0.2, 0.4, 0.6], [1e-07, 0.0, 0.0], [0.0, 0.3, 0.6]], + [[0.2, 0.4, 0.6], [0.05, 0.05, 0.05], [0.6, 0.6, 0.6]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 0.0], [0.9, 0.9, 0.9]], + [[0.9, 0.1, 0.35], [1.0, 1.0, 1.0], [0.9, 0.9, 0.9]], + [[0.9, 0.1, 0.35], [0.5, 0.5, 0.5], [0.9, 0.9, 0.9]], + [[0.9, 0.1, 0.35], [1.0, 0.0, 0.0], [0.8999999, 0.0, 0.28125]], + [[0.9, 0.1, 0.35], [0.0, 0.0, 1.0], [0.8999999, 0.0, 0.28125]], + [[0.9, 0.1, 0.35], [0.2, 0.4, 0.6], [0.9, 0.3, 0.4875]], + [[0.9, 0.1, 0.35], [0.9, 0.1, 0.35], [0.9, 0.1, 0.35]], + [[0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07], [0.9, 0.9, 0.9]], + [[0.9, 0.1, 0.35], [1e-07, 0.0, 0.0], [0.8999999, 0.0, 0.28125]], + [[0.9, 0.1, 0.35], [0.05, 0.05, 0.05], [0.9, 0.9, 0.9]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1.0, 1.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.5, 0.5, 0.5], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1.0, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.0, 0.0, 1.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.2, 0.4, 0.6], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.9, 0.1, 0.35], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [1e-07, 0.0, 0.0], [1e-07, 1e-07, 1e-07]], + [[1e-07, 0.0, 0.0], [0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1.0, 1.0, 1.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.5, 0.5, 0.5], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1.0, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.0, 0.0, 1.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.2, 0.4, 0.6], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.9, 0.1, 0.35], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1e-07, 1e-07, 1e-07], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [1e-07, 0.0, 0.0], [0.05, 0.05, 0.05]], + [[0.05, 0.05, 0.05], [0.05, 0.05, 0.05], [0.05, 0.05, 0.05]] + ] + } +} diff --git a/tests-unit/comfy_extras_test/compositor_blend_test.py b/tests-unit/comfy_extras_test/compositor_blend_test.py new file mode 100644 index 000000000..6734ab637 --- /dev/null +++ b/tests-unit/comfy_extras_test/compositor_blend_test.py @@ -0,0 +1,157 @@ +"""Blend-mode parity tests for the compositor. + +The compositor blends in three places: this numpy module (server-side), the +``layerBlend.frag`` GLSL shader (the live preview the user actually sees), and +anything the frontend adds later. They have diverged before, silently, and the +divergences only show up as "the render does not look like the preview". + +``compositor_blend_golden.json`` is the shared contract. This file pins the +numpy implementation to it and additionally spells out, by hand, the boundary +rules that the epsilon guards exist to enforce - so a future refactor of +``safe_div`` cannot quietly re-introduce the old behaviour by regenerating the +fixture. +""" + +import json +import os + +import numpy as np +import pytest + +from comfy_extras.compositor_blend import ( + CHANNEL_BLEND, + HSL_BLEND, + EffectiveMode, + blend_composite, + blend_pixel, + resolve_mode, +) + +GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "compositor_blend_golden.json") + +with open(GOLDEN_PATH) as _handle: + GOLDEN = json.load(_handle) + +TOLERANCE = GOLDEN["tolerance"] + + +def _blend(mode: str, i, l) -> np.ndarray: + return np.asarray( + blend_pixel(mode, np.float32(i), np.float32(l)), dtype=np.float64 + ).reshape(3) + + +def test_golden_covers_every_mode(): + """A new blend mode must arrive with golden values, not silently.""" + assert set(GOLDEN["channel"]) == set(CHANNEL_BLEND) + assert set(GOLDEN["hsl"]) == set(HSL_BLEND) + + +@pytest.mark.parametrize("mode", sorted(CHANNEL_BLEND)) +def test_channel_modes_match_golden(mode): + for i, l, expected in GOLDEN["channel"][mode]: + actual = _blend(mode, [i] * 3, [l] * 3) + assert actual == pytest.approx([expected] * 3, abs=TOLERANCE), ( + f"{mode}(i={i}, l={l}) -> {actual.tolist()}, golden {expected}" + ) + + +@pytest.mark.parametrize("mode", sorted(HSL_BLEND)) +def test_hsl_modes_match_golden(mode): + for i, l, expected in GOLDEN["hsl"][mode]: + actual = _blend(mode, i, l) + assert actual == pytest.approx(expected, abs=TOLERANCE), ( + f"{mode}(i={i}, l={l}) -> {actual.tolist()}, golden {expected}" + ) + + +@pytest.mark.parametrize("mode", sorted(set(CHANNEL_BLEND) | set(HSL_BLEND))) +def test_no_mode_produces_nan_or_inf(mode): + edges = [0.0, 1e-7, 1e-6, 0.5, 1.0 - 1e-7, 1.0] + for i in edges: + for l in edges: + out = _blend(mode, [i, 0.0, 1.0], [l, 1.0, 0.0]) + assert np.all(np.isfinite(out)), f"{mode}(i={i}, l={l}) -> {out.tolist()}" + + +class TestBoundaryRules: + """The rules the epsilon guards encode, written out independently of the fixture.""" + + def test_color_dodge_full_layer_is_white_not_black(self): + # Guarding the denominator returns 0 here, which reads as "the dodge + # layer turned the image black" - the exact inversion CodeRabbit flagged. + assert _blend("color-dodge", [0.5] * 3, [1.0] * 3) == pytest.approx([1.0] * 3) + + def test_color_dodge_black_backdrop_stays_black(self): + assert _blend("color-dodge", [0.0] * 3, [1.0] * 3) == pytest.approx([0.0] * 3) + + def test_color_dodge_is_clamped(self): + assert _blend("color-dodge", [0.6] * 3, [0.9] * 3) == pytest.approx([1.0] * 3) + + def test_color_burn_empty_layer_is_black_not_white(self): + assert _blend("color-burn", [0.5] * 3, [0.0] * 3) == pytest.approx([0.0] * 3) + + def test_color_burn_white_backdrop_stays_white(self): + assert _blend("color-burn", [1.0] * 3, [0.0] * 3) == pytest.approx([1.0] * 3) + + def test_vivid_light_boundaries(self): + assert _blend("vivid-light", [0.5] * 3, [0.0] * 3) == pytest.approx([0.0] * 3) + assert _blend("vivid-light", [0.5] * 3, [1.0] * 3) == pytest.approx([1.0] * 3) + assert _blend("vivid-light", [1.0] * 3, [0.0] * 3) == pytest.approx([1.0] * 3) + assert _blend("vivid-light", [0.0] * 3, [1.0] * 3) == pytest.approx([0.0] * 3) + + def test_divide_by_zero_is_clamped_to_one(self): + assert _blend("divide", [0.5] * 3, [0.0] * 3) == pytest.approx([1.0] * 3) + + def test_luminosity_over_black_takes_the_layer_luminance(self): + # A luminosity layer over a black backdrop must not vanish. There is no + # hue or saturation in the backdrop to preserve, so the result is a + # neutral grey at the layer's luminance. + assert _blend("luminosity", [0.0] * 3, [1.0] * 3) == pytest.approx([1.0] * 3) + assert _blend("luminosity", [0.0] * 3, [0.5] * 3) == pytest.approx([0.5] * 3) + + def test_luminosity_is_continuous_approaching_black(self): + near = _blend("luminosity", [1e-7] * 3, [1.0] * 3) + at = _blend("luminosity", [0.0] * 3, [1.0] * 3) + assert near == pytest.approx(at, abs=TOLERANCE) + + def test_luminosity_preserves_backdrop_chroma(self): + out = _blend("luminosity", [0.4, 0.2, 0.1], [0.5] * 3) + assert out[0] > out[1] > out[2] + + +class TestCompositeAndModeTable: + def test_unknown_blend_mode_falls_back_to_normal(self): + unknown = resolve_mode("not-a-mode") + assert (unknown.blend_space, unknown.composite) == ( + resolve_mode("normal").blend_space, + resolve_mode("normal").composite, + ) + assert _blend("not-a-mode", [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]) == pytest.approx( + _blend("normal", [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]) + ) + + def test_every_blend_mode_has_a_composite_entry(self): + for mode in set(CHANNEL_BLEND) | set(HSL_BLEND): + resolved = resolve_mode(mode) + assert isinstance(resolved, EffectiveMode) + assert resolved.blend == mode + assert resolved.blend_space in ("linear", "perceptual") + assert resolved.composite in ( + "union", + "clip-to-backdrop", + "clip-to-layer", + "intersection", + ) + + def test_normal_over_transparent_backdrop_keeps_the_layer(self): + backdrop = np.zeros((1, 1, 4), dtype=np.float32) + layer = np.float32([[[0.25, 0.5, 0.75, 1.0]]]) + out = blend_composite(resolve_mode("normal"), backdrop, layer, 1.0) + assert out[0, 0].tolist() == pytest.approx([0.25, 0.5, 0.75, 1.0]) + + def test_zero_opacity_is_a_no_op(self): + backdrop = np.float32([[[0.1, 0.2, 0.3, 1.0]]]) + layer = np.float32([[[1.0, 1.0, 1.0, 1.0]]]) + out = blend_composite(resolve_mode("multiply"), backdrop, layer, 0.0) + assert out[0, 0].tolist() == pytest.approx([0.1, 0.2, 0.3, 1.0]) diff --git a/tests-unit/comfy_extras_test/compositor_node_test.py b/tests-unit/comfy_extras_test/compositor_node_test.py new file mode 100644 index 000000000..3a24c4dff --- /dev/null +++ b/tests-unit/comfy_extras_test/compositor_node_test.py @@ -0,0 +1,72 @@ +"""Regression tests for ImageCompositor's handling of untrusted layer state. + +The compositor's `compositor` widget value is persisted into the saved workflow +and is accepted verbatim on `POST /prompt`, so every field in it is untrusted +input, not an internal invariant. +""" + +import numpy as np +import pytest +import torch + +from comfy_extras.nodes_compositor import ( + _layer_params, + composite_from_state, + expand_item_frames, + state_from_items, +) + + +def _solid(color, w=4, h=4) -> torch.Tensor: + frame = np.zeros((h, w, len(color)), dtype=np.float32) + frame[:] = color + return torch.from_numpy(frame).unsqueeze(0) + + +class TestLayerOpacity: + @pytest.mark.parametrize( + ("raw", "expected"), + [(-0.5, 0.0), (0.0, 0.0), (0.25, 0.25), (1.0, 1.0), (3.0, 1.0)], + ) + def test_opacity_is_clamped(self, raw, expected): + assert _layer_params({"opacity": raw}, 4, 4)["opacity"] == expected + + def test_opacity_defaults_to_opaque(self): + assert _layer_params({}, 4, 4)["opacity"] == 1.0 + + def test_out_of_range_opacity_does_not_leak_into_the_next_layer(self): + # The canvas is only clamped once, after every layer has been composited, + # so an out-of-range coverage multiplier on one layer changes the *blend* + # of the layer above it. White at opacity 3.0 over black leaves the canvas + # at 3.0; the multiply above it then reads 3.0 as its backdrop and the + # result is visibly lighter than the same stack at opacity 1.0. + def run(opacity): + state = { + "canvas": (2, 2), + "layers": [{"opacity": opacity}, {"opacity": 1.0, "blend": "multiply"}], + "inputs": None, + "background": {"color": "#000000", "opacity": 1.0, "visible": True}, + "order": None, + } + tensors = [_solid([1.0, 1.0, 1.0], 2, 2), _solid([0.5, 0.5, 0.5], 2, 2)] + return composite_from_state(tensors, state, [None, None])[0, 0, 0, :3] + + assert run(3.0).tolist() == pytest.approx(run(1.0).tolist(), abs=1e-6) + + +class TestGraphOnlyBackground: + 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. + 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)] + 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) + assert float(out[3, 3, 3]) == pytest.approx(0.0)