diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 58e49d8e2d2..6d9e08f478f 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 @@ def as_dict(self): "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 515e124ca84..7ee83e5f3c4 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.") @@ -95,9 +112,10 @@ class Seedance2TaskCreationRequest(BaseModel): generate_audio: bool | None = Field(None) resolution: str | None = Field(None) ratio: str | None = Field(None) - duration: int | None = Field(None, ge=4, le=15) + duration: int | None = Field(None) seed: int | None = Field(None, ge=0, le=2147483647) watermark: bool | None = Field(None) + output_format: str | None = Field(None) class TaskCreationResponse(BaseModel): @@ -186,6 +204,10 @@ class SeedanceVirtualLibraryCreateAssetRequest(BaseModel): ("dreamina-seedance-2-0-mini", True, "480p"): 0.0021, ("dreamina-seedance-2-0-mini", False, "720p"): 0.0035, ("dreamina-seedance-2-0-mini", True, "720p"): 0.0021, + ("dreamina-seedance-2-5-260628", False, "480p"): 0.0107, + ("dreamina-seedance-2-5-260628", True, "480p"): 0.0064, + ("dreamina-seedance-2-5-260628", False, "720p"): 0.0107, + ("dreamina-seedance-2-5-260628", True, "720p"): 0.0064, } @@ -304,7 +326,30 @@ def seedance2_price_per_1k_tokens(model_id: str, has_video_input: bool, resoluti "480p": {"min": 409_600, "max": 927_408}, "720p": {"min": 409_600, "max": 927_408}, }, + "dreamina-seedance-2-5-260628": { + "480p": {"min": 409_600, "max": 8_295_044}, + "720p": {"min": 409_600, "max": 8_295_044}, + }, +} + +SEEDANCE2_REFERENCE_LIMITS_DEFAULT = { + "max_images": 9, + "max_videos": 3, + "max_audios": 3, + "max_total_seconds": 15.1, } +SEEDANCE2_REFERENCE_LIMITS = { + "dreamina-seedance-2-5-260628": { + "max_images": 30, + "max_videos": 10, + "max_audios": 10, + "max_total_seconds": 30.1, + }, +} + + +def seedance2_reference_limits(model_id: str) -> dict: + return SEEDANCE2_REFERENCE_LIMITS.get(model_id, SEEDANCE2_REFERENCE_LIMITS_DEFAULT) # The time in this dictionary are given for 10 seconds duration. VIDEO_TASKS_EXECUTION_TIME = { diff --git a/comfy_api_nodes/nodes_bytedance.py b/comfy_api_nodes/nodes_bytedance.py index 561d6ae80e2..09fc445d8d6 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 @@ SeedanceVirtualLibraryCreateAssetRequest, Seedream4Options, Seedream4TaskCreationRequest, + Seedream5LayerOptimizePromptOptions, + Seedream5LayerSeparationRequest, Seedream5OptimizePromptOptions, TaskAudioContent, TaskAudioContentUrl, @@ -47,6 +50,7 @@ Text2ImageTaskCreationRequest, Text2VideoTaskCreationRequest, seedance2_price_per_1k_tokens, + seedance2_reference_limits, ) from comfy_api_nodes.util import ( ApiEndpoint, @@ -74,6 +78,7 @@ 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" @@ -95,17 +100,26 @@ "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} BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT = "/proxy/byteplus-seedance2/api/v3/contents/generations/tasks" # + /{task_id} SEEDANCE_MODELS = { + "Seedance 2.5": "dreamina-seedance-2-5-260628", "Seedance 2.0": "dreamina-seedance-2-0-260128", "Seedance 2.0 Fast": "dreamina-seedance-2-0-fast-260128", "Seedance 2.0 Mini": "dreamina-seedance-2-0-mini", } +SEEDANCE_MODEL_TOOLTIP = ( + "Seedance 2.5 for the newest model, videos up to 30 seconds and mp4/mov output; " + "Seedance 2.0 for maximum quality and 1080p/4k; Fast for speed optimization; " + "Mini for the fastest, lowest-cost generation." +) + DEPRECATED_MODELS = {"seedance-1-0-lite-t2v-250428", "seedance-1-0-lite-i2v-250428"} @@ -1036,6 +1050,369 @@ async def execute( 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 @@ -1692,25 +2069,272 @@ def _seedance2_text_inputs(resolutions: list[str], default_ratio: str = "16:9"): ] +def _seedance25_text_inputs(with_ratio: bool = True, with_video_editing: bool = False): + return [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Text prompt for video generation. Put spoken lines in double quotes to steer " + "the generated dialogue.", + ), + IO.Combo.Input( + "resolution", + options=["480p", "720p"], + default="720p", + tooltip="Resolution of the output video.", + ), + *( + [ + IO.Combo.Input( + "ratio", + options=["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], + default="16:9", + tooltip="Aspect ratio of the output video.", + ) + ] + if with_ratio + else [] + ), + IO.Int.Input( + "duration", + default=5, + min=4, + max=30, + step=1, + tooltip="Duration of the output video in seconds (4-30).", + display_mode=IO.NumberDisplay.slider, + ), + IO.Boolean.Input( + "generate_audio", + default=True, + tooltip="Enable audio generation for the output video.", + ), + *( + [ + IO.Boolean.Input( + "video_editing", + default=False, + tooltip="Enable when the prompt edits a connected reference video, for example " + "replacing an object in it. The output then keeps the source clip's own length " + "and aspect ratio, and the duration and ratio widgets are ignored. Leave " + "disabled to generate a new video, or to extend one to the duration you set.", + ) + ] + if with_video_editing + else [] + ), + IO.Combo.Input( + "output_format", + options=["mp4"], + default="mp4", + tooltip="Container format of the output video.", + ), + ] + + +def _seedance25_reference_inputs(): + return [ + *_seedance25_text_inputs(with_video_editing=True), + IO.Autogrow.Input( + "reference_images", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("reference_image"), + names=[f"image_{i}" for i in range(1, 31)], + min=0, + ), + ), + IO.Autogrow.Input( + "reference_videos", + template=IO.Autogrow.TemplateNames( + IO.Video.Input("reference_video"), + names=[f"video_{i}" for i in range(1, 11)], + min=0, + ), + ), + IO.Autogrow.Input( + "reference_audios", + template=IO.Autogrow.TemplateNames( + IO.Audio.Input("reference_audio"), + names=[f"audio_{i}" for i in range(1, 11)], + min=0, + ), + ), + IO.Boolean.Input( + "auto_downscale", + default=True, + optional=True, + tooltip="Automatically downscale reference videos that exceed the model's pixel budget " + "for the selected resolution. Aspect ratio is preserved; videos already within limits are untouched.", + ), + IO.Boolean.Input( + "auto_upscale", + default=False, + advanced=True, + optional=True, + tooltip="Automatically upscale reference videos that are below the model's minimum pixel count " + "for the selected resolution. Aspect ratio is preserved; videos already meeting the minimum are " + "untouched. Note: upscaling a low-resolution source does not add real detail and may produce " + "lower-quality generations.", + ), + IO.Autogrow.Input( + "reference_assets", + template=IO.Autogrow.TemplateNames( + IO.String.Input("reference_asset"), + names=[f"asset_{i}" for i in range(1, 31)], + min=0, + ), + ), + ] + + +def _seedance2_build_request( + model: dict, + model_id: str, + content: list, + seed: int, + watermark: bool, + ratio: str, +) -> Seedance2TaskCreationRequest: + video_editing = bool(model.get("video_editing")) + return Seedance2TaskCreationRequest( + model=model_id, + content=content, + generate_audio=model["generate_audio"], + resolution=model["resolution"], + ratio="adaptive" if video_editing else ratio, + duration=-1 if video_editing else model["duration"], + seed=seed, + watermark=watermark, + output_format=model.get("output_format"), + ) + + +_SEEDANCE2_PRICE_EXPR_TEMPLATE = """ +( + $m := widgets.model; + $res := $lookup(widgets, "model.resolution"); + $ratio := $lookup(widgets, "model.ratio"); + $dur := $lookup(widgets, "model.duration"); + $auto := $lookup(widgets, "model.video_editing") = true; + $hasVideo := __HAS_VIDEO__; + $ready := $type($m) = "string" and $type($res) = "string" and ($auto or $type($dur) = "number"); + $ready ? ( + $contains($m, "2.5") ? ( + $is480 := $res = "480p"; + $perFrame := $ratio = "1:1" ? ($is480 ? 400 : 900) : + $ratio = "4:3" ? ($is480 ? 411.25 : 905.6719) : + $ratio = "3:4" ? ($is480 ? 411.25 : 905.6719) : + $ratio = "21:9" ? ($is480 ? 418.5 : 904.3945) : + ($is480 ? 400.3125 : 900); + $price := $hasVideo ? 0.009152 : 0.015301; + $costFor := function($d) { $floor($perFrame * (24 * $d + 1)) / 1000 * $price }; + $lo := $costFor($auto ? 4 : $dur); + $hi := $costFor(($auto ? 30 : $dur) + ($hasVideo ? 30 : 0)); + $lo = $hi + ? {"type": "usd", "usd": $lo, "format": {"approximate": true}} + : {"type": "range_usd", "min_usd": $lo, "max_usd": $hi, "format": {"approximate": true}} + ) : ( + $rate := $res = "4k" ? 195200 : + $res = "1080p" ? 48800 : + $res = "720p" ? 21600 : 10044; + $noVideoPrice := $res = "4k" ? 0.00572 : + $res = "1080p" ? 0.011011 : + $contains($m, "mini") ? 0.005005 : + $contains($m, "fast") ? 0.008008 : 0.01001; + $videoPrice := $res = "4k" ? 0.003432 : + $res = "1080p" ? 0.006721 : + $contains($m, "mini") ? 0.003003 : + $contains($m, "fast") ? 0.004719 : 0.006149; + $hasVideo + ? {"type": "range_usd", + "min_usd": $ceil($dur * 5 / 3) * $rate * $videoPrice / 1000, + "max_usd": (15 + $dur) * $rate * $videoPrice / 1000, + "format": {"approximate": true}} + : {"type": "usd", "usd": $dur * $rate * $noVideoPrice / 1000, + "format": {"approximate": true}} + ) + ) : undefined +) +""" + + +_SEEDANCE_AUDIO_POLICY_CODE = "OutputAudioSensitiveContentDetected.PolicyViolation" +_SEEDANCE_TASK_TYPE_CONSTRAINT_CODE = "InvalidParameter.TaskTypeConstraint" + + +async def _seedance2_poll_video_task( + cls: type[IO.ComfyNode], + task_id: str, + model_id: str, + resolution: str, + has_video_input: bool, +) -> TaskStatusResponse: + try: + return await poll_op( + cls, + ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{task_id}"), + response_model=TaskStatusResponse, + status_extractor=lambda r: r.status, + price_extractor=_seedance2_price_extractor( + model_id, has_video_input=has_video_input, resolution=resolution + ), + poll_interval=9, + ) + except Exception as exc: + if _SEEDANCE_AUDIO_POLICY_CODE in str(exc): + raise ValueError( + "The provider rejected the audio track this model generated for the video " + "(possible copyright match). The video itself was fine. Turn off generate_audio " + "to get a silent video, or adjust the prompt and try again." + ) from exc + if _SEEDANCE_TASK_TYPE_CONSTRAINT_CODE in str(exc): + raise ValueError( + "Seedance read this prompt as editing the reference video, and an edit always " + "takes its duration and aspect ratio from that video. Enable video_editing on " + "this node and run again, or reword the prompt so it describes a new video " + "rather than a change to the reference one." + ) from exc + raise + + +def _seedance2_price_badge(with_reference_videos: bool) -> IO.PriceBadge: + widgets = ["model", "model.resolution", "model.ratio", "model.duration"] + if with_reference_videos: + widgets.append("model.video_editing") + has_video = ( + '$exists(inputGroups) and $lookup(inputGroups, "model.reference_videos") > 0' + if with_reference_videos + else "false" + ) + return IO.PriceBadge( + depends_on=IO.PriceBadgeDepends( + widgets=widgets, + input_groups=["model.reference_videos"] if with_reference_videos else [], + ), + expr=_SEEDANCE2_PRICE_EXPR_TEMPLATE.replace("__HAS_VIDEO__", has_video), + ) + + class ByteDance2TextToVideoNode(IO.ComfyNode): @classmethod def define_schema(cls): return IO.Schema( node_id="ByteDance2TextToVideoNode", - display_name="ByteDance Seedance 2.0 Text to Video", + display_name="ByteDance Seedance 2.5 Text to Video", category="partner/video/ByteDance", - description="Generate video using Seedance 2.0 models based on a text prompt.", + description="Generate video using Seedance 2.5 or 2.0 models based on a text prompt.", inputs=[ IO.DynamicCombo.Input( "model", options=[ + IO.DynamicCombo.Option("Seedance 2.5", _seedance25_text_inputs()), IO.DynamicCombo.Option("Seedance 2.0", _seedance2_text_inputs(["480p", "720p", "1080p", "4k"])), IO.DynamicCombo.Option("Seedance 2.0 Fast", _seedance2_text_inputs(["480p", "720p"])), IO.DynamicCombo.Option("Seedance 2.0 Mini", _seedance2_text_inputs(["480p", "720p"])), ], - tooltip="Seedance 2.0 for maximum quality; Fast for speed optimization; " - "Mini for the fastest, lowest-cost generation.", + tooltip=SEEDANCE_MODEL_TOOLTIP, ), IO.Int.Input( "seed", @@ -1739,30 +2363,7 @@ def define_schema(cls): IO.Hidden.unique_id, ], is_api_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution", "model.duration"]), - expr=""" - ( - $rate480 := 10044; - $rate720 := 21600; - $rate1080 := 48800; - $rate4k := 195200; - $m := widgets.model; - $res := $lookup(widgets, "model.resolution"); - $dur := $lookup(widgets, "model.duration"); - $pricePer1K := $res = "4k" ? 0.00572 : - $res = "1080p" ? 0.011011 : - $contains($m, "mini") ? 0.005005 : - $contains($m, "fast") ? 0.008008 : 0.01001; - $rate := $res = "4k" ? $rate4k : - $res = "1080p" ? $rate1080 : - $res = "720p" ? $rate720 : - $rate480; - $cost := $dur * $rate * $pricePer1K / 1000; - {"type": "usd", "usd": $cost, "format": {"approximate": true}} - ) - """, - ), + price_badge=_seedance2_price_badge(with_reference_videos=False), ) @classmethod @@ -1777,25 +2378,18 @@ async def execute( initial_response = await sync_op( cls, ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"), - data=Seedance2TaskCreationRequest( - model=model_id, - content=[TaskTextContent(text=model["prompt"])], - generate_audio=model["generate_audio"], - resolution=model["resolution"], + data=_seedance2_build_request( + model, + model_id, + [TaskTextContent(text=model["prompt"])], + seed, + watermark, ratio=model["ratio"], - duration=model["duration"], - seed=seed, - watermark=watermark, ), response_model=TaskCreationResponse, ) - response = await poll_op( - cls, - ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{initial_response.id}"), - response_model=TaskStatusResponse, - status_extractor=lambda r: r.status, - price_extractor=_seedance2_price_extractor(model_id, has_video_input=False, resolution=model["resolution"]), - poll_interval=9, + response = await _seedance2_poll_video_task( + cls, initial_response.id, model_id, model["resolution"], has_video_input=False ) return IO.NodeOutput(await download_url_to_video_output(response.content.video_url)) @@ -1806,13 +2400,15 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ByteDance2FirstLastFrameNode", - display_name="ByteDance Seedance 2.0 First-Last-Frame to Video", + display_name="ByteDance Seedance 2.5 First-Last-Frame to Video", category="partner/video/ByteDance", - description="Generate video using Seedance 2.0 from a first frame image and optional last frame image.", + description="Generate video using Seedance 2.5 or 2.0 from a first frame image " + "and optional last frame image.", inputs=[ IO.DynamicCombo.Input( "model", options=[ + IO.DynamicCombo.Option("Seedance 2.5", _seedance25_text_inputs(with_ratio=False)), IO.DynamicCombo.Option( "Seedance 2.0", _seedance2_text_inputs(["480p", "720p", "1080p", "4k"], default_ratio="adaptive"), @@ -1826,8 +2422,7 @@ def define_schema(cls): _seedance2_text_inputs(["480p", "720p"], default_ratio="adaptive"), ), ], - tooltip="Seedance 2.0 for maximum quality; Fast for speed optimization; " - "Mini for the fastest, lowest-cost generation.", + tooltip=SEEDANCE_MODEL_TOOLTIP, ), IO.Image.Input( "first_frame", @@ -1880,30 +2475,7 @@ def define_schema(cls): IO.Hidden.unique_id, ], is_api_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution", "model.duration"]), - expr=""" - ( - $rate480 := 10044; - $rate720 := 21600; - $rate1080 := 48800; - $rate4k := 195200; - $m := widgets.model; - $res := $lookup(widgets, "model.resolution"); - $dur := $lookup(widgets, "model.duration"); - $pricePer1K := $res = "4k" ? 0.00572 : - $res = "1080p" ? 0.011011 : - $contains($m, "mini") ? 0.005005 : - $contains($m, "fast") ? 0.008008 : 0.01001; - $rate := $res = "4k" ? $rate4k : - $res = "1080p" ? $rate1080 : - $res = "720p" ? $rate720 : - $rate480; - $cost := $dur * $rate * $pricePer1K / 1000; - {"type": "usd", "usd": $cost, "format": {"approximate": true}} - ) - """, - ), + price_badge=_seedance2_price_badge(with_reference_videos=False), ) @classmethod @@ -1930,8 +2502,17 @@ async def execute( if last_frame is not None and last_frame_asset_id: raise ValueError("Provide only one of last_frame or last_frame_asset_id, not both.") - request_ratio = model["ratio"] - if first_frame_asset_id or last_frame_asset_id: + if model_id == "dreamina-seedance-2-5-260628": + # 2.5 accepts ratio="adaptive" only here and keeps the first frame's own aspect + # (a 1920x1088 frame yields 850x482, not a grid ratio), so pre-sizing the frames to a + # supported pixel pair would crop framing the model would otherwise have preserved. + request_ratio = "adaptive" + if first_frame is not None: + first_frame = _prepare_seedance_image(first_frame) + if last_frame is not None: + last_frame = _prepare_seedance_image(last_frame) + elif first_frame_asset_id or last_frame_asset_id: + request_ratio = model["ratio"] if first_frame is not None: first_frame = _prepare_seedance_image(first_frame) if last_frame is not None: @@ -1997,25 +2578,11 @@ async def execute( initial_response = await sync_op( cls, ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"), - data=Seedance2TaskCreationRequest( - model=model_id, - content=content, - generate_audio=model["generate_audio"], - resolution=model["resolution"], - ratio=request_ratio, - duration=model["duration"], - seed=seed, - watermark=watermark, - ), + data=_seedance2_build_request(model, model_id, content, seed, watermark, ratio=request_ratio), response_model=TaskCreationResponse, ) - response = await poll_op( - cls, - ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{initial_response.id}"), - response_model=TaskStatusResponse, - status_extractor=lambda r: r.status, - price_extractor=_seedance2_price_extractor(model_id, has_video_input=False, resolution=model["resolution"]), - poll_interval=9, + response = await _seedance2_poll_video_task( + cls, initial_response.id, model_id, model["resolution"], has_video_input=False ) return IO.NodeOutput(await download_url_to_video_output(response.content.video_url)) @@ -2101,14 +2668,15 @@ class ByteDance2ReferenceNode(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ByteDance2ReferenceNode", - display_name="ByteDance Seedance 2.0 Reference to Video", + display_name="ByteDance Seedance 2.5 Reference to Video", category="partner/video/ByteDance", - description="Generate, edit, or extend video using Seedance 2.0 with reference images, " - "videos, and audio. Supports multimodal reference, video editing, and video extension.", + description="Generate, edit, or extend video using Seedance 2.5 or 2.0 with reference " + "images, videos, and audio. Supports multimodal reference, video editing, and video extension.", inputs=[ IO.DynamicCombo.Input( "model", options=[ + IO.DynamicCombo.Option("Seedance 2.5", _seedance25_reference_inputs()), IO.DynamicCombo.Option( "Seedance 2.0", _seedance2_reference_inputs(["480p", "720p", "1080p", "4k"], default_ratio="adaptive"), @@ -2122,8 +2690,7 @@ def define_schema(cls): _seedance2_reference_inputs(["480p", "720p"], default_ratio="adaptive"), ), ], - tooltip="Seedance 2.0 for maximum quality; Fast for speed optimization; " - "Mini for the fastest, lowest-cost generation.", + tooltip=SEEDANCE_MODEL_TOOLTIP, ), IO.Int.Input( "seed", @@ -2152,52 +2719,7 @@ def define_schema(cls): IO.Hidden.unique_id, ], is_api_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends( - widgets=["model", "model.resolution", "model.duration"], - input_groups=["model.reference_videos"], - ), - expr=""" - ( - $rate480 := 10044; - $rate720 := 21600; - $rate1080 := 48800; - $rate4k := 195200; - $m := widgets.model; - $hasVideo := $lookup(inputGroups, "model.reference_videos") > 0; - $res := $lookup(widgets, "model.resolution"); - $dur := $lookup(widgets, "model.duration"); - $noVideoPricePer1K := $res = "4k" ? 0.00572 : - $res = "1080p" ? 0.011011 : - $contains($m, "mini") ? 0.005005 : - $contains($m, "fast") ? 0.008008 : 0.01001; - $videoPricePer1K := $res = "4k" ? 0.003432 : - $res = "1080p" ? 0.006721 : - $contains($m, "mini") ? 0.003003 : - $contains($m, "fast") ? 0.004719 : 0.006149; - $rate := $res = "4k" ? $rate4k : - $res = "1080p" ? $rate1080 : - $res = "720p" ? $rate720 : - $rate480; - $noVideoCost := $dur * $rate * $noVideoPricePer1K / 1000; - $minVideoFactor := $ceil($dur * 5 / 3); - $minVideoCost := $minVideoFactor * $rate * $videoPricePer1K / 1000; - $maxVideoCost := (15 + $dur) * $rate * $videoPricePer1K / 1000; - $hasVideo - ? { - "type": "range_usd", - "min_usd": $minVideoCost, - "max_usd": $maxVideoCost, - "format": {"approximate": true} - } - : { - "type": "usd", - "usd": $noVideoCost, - "format": {"approximate": true} - } - ) - """, - ), + price_badge=_seedance2_price_badge(with_reference_videos=True), ) @classmethod @@ -2218,32 +2740,38 @@ async def execute( cls, list(reference_assets.values()) ) + model_id = SEEDANCE_MODELS[model["model"]] + limits = seedance2_reference_limits(model_id) + if not reference_images and not reference_videos and not reference_image_assets and not reference_video_assets: - raise ValueError("At least one reference image or video or asset is required.") + if model_id != "dreamina-seedance-2-5-260628" or not (reference_audios or reference_audio_assets): + raise ValueError("At least one reference image or video or asset is required.") total_images = len(reference_images) + len(reference_image_assets) - if total_images > 9: + if total_images > limits["max_images"]: raise ValueError( f"Too many reference images: {total_images} " - f"(images={len(reference_images)}, image assets={len(reference_image_assets)}). Maximum is 9." + f"(images={len(reference_images)}, image assets={len(reference_image_assets)}). " + f"Maximum is {limits['max_images']}." ) total_videos = len(reference_videos) + len(reference_video_assets) - if total_videos > 3: + if total_videos > limits["max_videos"]: raise ValueError( f"Too many reference videos: {total_videos} " - f"(videos={len(reference_videos)}, video assets={len(reference_video_assets)}). Maximum is 3." + f"(videos={len(reference_videos)}, video assets={len(reference_video_assets)}). " + f"Maximum is {limits['max_videos']}." ) total_audios = len(reference_audios) + len(reference_audio_assets) - if total_audios > 3: + if total_audios > limits["max_audios"]: raise ValueError( f"Too many reference audios: {total_audios} " - f"(audios={len(reference_audios)}, audio assets={len(reference_audio_assets)}). Maximum is 3." + f"(audios={len(reference_audios)}, audio assets={len(reference_audio_assets)}). " + f"Maximum is {limits['max_audios']}." ) for key in reference_images: reference_images[key] = _prepare_seedance_image(reference_images[key]) - model_id = SEEDANCE_MODELS[model["model"]] has_video_input = total_videos > 0 if model.get("auto_downscale") and reference_videos: @@ -2271,8 +2799,11 @@ async def execute( raise except Exception: pass - if total_video_duration > 15.1: - raise ValueError(f"Total reference video duration is {total_video_duration:.1f}s. Maximum is 15.1 seconds.") + if total_video_duration > limits["max_total_seconds"]: + raise ValueError( + f"Total reference video duration is {total_video_duration:.1f}s. " + f"Maximum is {limits['max_total_seconds']} seconds." + ) total_audio_duration = 0.0 for i, key in enumerate(reference_audios, 1): @@ -2281,8 +2812,11 @@ async def execute( if dur < 1.8: raise ValueError(f"Reference audio {i} is too short: {dur:.1f}s. Minimum duration is 1.8 seconds.") total_audio_duration += dur - if total_audio_duration > 15.1: - raise ValueError(f"Total reference audio duration is {total_audio_duration:.1f}s. Maximum is 15.1 seconds.") + if total_audio_duration > limits["max_total_seconds"]: + raise ValueError( + f"Total reference audio duration is {total_audio_duration:.1f}s. " + f"Maximum is {limits['max_total_seconds']} seconds." + ) asset_labels = _build_asset_labels( reference_assets, @@ -2355,27 +2889,11 @@ async def execute( initial_response = await sync_op( cls, ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"), - data=Seedance2TaskCreationRequest( - model=model_id, - content=content, - generate_audio=model["generate_audio"], - resolution=model["resolution"], - ratio=model["ratio"], - duration=model["duration"], - seed=seed, - watermark=watermark, - ), + data=_seedance2_build_request(model, model_id, content, seed, watermark, ratio=model["ratio"]), response_model=TaskCreationResponse, ) - response = await poll_op( - cls, - ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{initial_response.id}"), - response_model=TaskStatusResponse, - status_extractor=lambda r: r.status, - price_extractor=_seedance2_price_extractor( - model_id, has_video_input=has_video_input, resolution=model["resolution"] - ), - poll_interval=9, + response = await _seedance2_poll_video_task( + cls, initial_response.id, model_id, model["resolution"], has_video_input=has_video_input ) return IO.NodeOutput(await download_url_to_video_output(response.content.video_url)) @@ -2887,6 +3405,7 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: 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 00000000000..13476e781a9 --- /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 00000000000..10cea3adcf3 --- /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 08529d8dc41..432f04d8951 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/requirements.txt b/requirements.txt index 3abbd138e4a..771504af40d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -comfyui-frontend-package==1.48.6 -comfyui-workflow-templates==0.11.31 +comfyui-frontend-package==1.48.7 +comfyui-workflow-templates==0.11.34 comfyui-embedded-docs==0.5.9 torch torchsde @@ -22,7 +22,7 @@ alembic SQLAlchemy>=2.0.0 filelock av>=16.0.0 -comfy-kitchen==0.2.27 +comfy-kitchen==0.2.28 comfy-aimdo==0.4.13 requests simpleeval>=1.0.0 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 00000000000..ea7a110e4aa --- /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 00000000000..65a06ead733 --- /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 00000000000..6734ab637af --- /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 00000000000..3a24c4dffeb --- /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)