feat(segmentation): add interactive SEGS preview

This commit is contained in:
Artificial Sweetener
2026-08-02 02:43:27 -04:00
parent 1972e452dc
commit 823fe209d8
37 changed files with 3368 additions and 88 deletions
+118
View File
@@ -0,0 +1,118 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Plan deterministic visual representations of validated SEGS."""
from __future__ import annotations
from dataclasses import dataclass
from typing import NamedTuple
import torch
from .segs import CropRegion, NativeSegs, Segment, coerce_segment_mask, coerce_segs
class RegionColor(NamedTuple):
"""Represent one reusable RGB region color."""
red: int
green: int
blue: int
@property
def normalized(self) -> tuple[float, float, float]:
"""Return the color as normalized tensor-ready channels."""
return self.red / 255.0, self.green / 255.0, self.blue / 255.0
@property
def css(self) -> str:
"""Return the color as a browser-ready hexadecimal value."""
return f"#{self.red:02x}{self.green:02x}{self.blue:02x}"
REGION_COLORS: tuple[RegionColor, ...] = (
RegionColor(242, 66, 54),
RegionColor(33, 150, 243),
RegionColor(76, 176, 80),
RegionColor(255, 194, 8),
RegionColor(156, 39, 176),
RegionColor(255, 87, 34),
RegionColor(0, 188, 212),
RegionColor(232, 31, 99),
RegionColor(140, 194, 74),
RegionColor(103, 58, 183),
RegionColor(255, 153, 0),
RegionColor(0, 150, 136),
)
@dataclass(frozen=True)
class VisualRegion:
"""Describe one validated SEG with stable visualization identity."""
region_id: str
index: int
segment: Segment
mask: torch.Tensor
color: RegionColor
active_area: int
@property
def crop_region(self) -> CropRegion:
"""Return the source crop occupied by this region."""
return self.segment.crop_region
@dataclass(frozen=True)
class SegVisualizationPlan:
"""Own source geometry and ordered visual regions for one SEGS payload."""
source_height: int
source_width: int
regions: tuple[VisualRegion, ...]
def build_seg_visualization_plan(segs: NativeSegs) -> SegVisualizationPlan:
"""Return deterministic validated regions without mutating the source SEGS."""
(source_height, source_width), segments = coerce_segs(segs)
regions: list[VisualRegion] = []
for index, segment in enumerate(segments):
_validate_crop_bounds(
segment.crop_region,
source_height=source_height,
source_width=source_width,
)
mask = coerce_segment_mask(segment).detach().cpu()
regions.append(
VisualRegion(
region_id=f"seg-{index + 1:04d}",
index=index,
segment=segment,
mask=mask,
color=REGION_COLORS[index % len(REGION_COLORS)],
active_area=int((mask >= 0.5).sum().item()),
)
)
return SegVisualizationPlan(
source_height=source_height,
source_width=source_width,
regions=tuple(regions),
)
def _validate_crop_bounds(
crop: CropRegion,
*,
source_height: int,
source_width: int,
) -> None:
"""Reject SEG crops that cannot describe the declared source image."""
if crop.right > source_width or crop.bottom > source_height:
raise ValueError("Segment crop_region must fit inside the SEGS dimensions.")
+21
View File
@@ -10,6 +10,8 @@ from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import NamedTuple, Protocol, TypeAlias, cast
import torch
class CropRegion(NamedTuple):
"""Represent a crop region as left, top, right, bottom coordinates."""
@@ -152,6 +154,25 @@ def coerce_segment(value: object) -> Segment:
)
def coerce_segment_mask(segment: Segment) -> torch.Tensor:
"""Return one validated crop-local HW mask without changing its values."""
mask = (
segment.cropped_mask.float()
if isinstance(segment.cropped_mask, torch.Tensor)
else torch.as_tensor(segment.cropped_mask, dtype=torch.float32)
)
if mask.ndim == 3 and int(mask.shape[0]) == 1:
mask = mask.squeeze(0)
if mask.ndim != 2:
raise ValueError("Segment cropped_mask must be HW or singleton BHW shaped.")
expected_shape = (segment.crop_region.height, segment.crop_region.width)
actual_shape = (int(mask.shape[0]), int(mask.shape[1]))
if actual_shape != expected_shape:
raise ValueError("Segment cropped_mask must match its crop region.")
return mask.clamp(0.0, 1.0)
def to_impact_compatible_segs(segs: NativeSegs) -> ImpactSegs:
"""Return raw tuple/list SEGS that Impact-style consumers can read."""
+2 -11
View File
@@ -11,7 +11,7 @@ from dataclasses import dataclass
import torch
import torch.nn.functional as functional
from .segs import NativeSegs, Segment, coerce_segs
from .segs import NativeSegs, Segment, coerce_segment_mask, coerce_segs
from .tiled_diffusion import (
LatentTile,
TiledDiffusionPlan,
@@ -215,16 +215,7 @@ def segment_weight_to_latent(
"SEGS-guided tiled diffusion requires every SEG crop_region to fit "
"inside the SEGS header dimensions."
)
local_mask = (
torch.as_tensor(segment.cropped_mask, dtype=torch.float32).detach().cpu()
)
if local_mask.ndim == 3 and int(local_mask.shape[0]) == 1:
local_mask = local_mask.squeeze(0)
if local_mask.shape != (crop.height, crop.width):
raise ValueError(
"SEGS-guided tiled diffusion requires each cropped_mask to match its "
"crop_region."
)
local_mask = coerce_segment_mask(segment).detach().cpu()
latent_top, latent_bottom = _latent_sample_range(
crop.top,
crop.bottom,
+64
View File
@@ -0,0 +1,64 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""ComfyUI node declaration for the interactive Simple SEGS inspector."""
from __future__ import annotations
from typing import Any, ClassVar
from ..domain.segs import coerce_segs
from ..runtime.seg_preview_assets import (
SEG_PREVIEW_UI_KEY,
ComfySegPreviewAssetPublisher,
)
from ..services.simple_preview_segs_service import SimplePreviewSEGSService
class SimplePreviewSEGS:
"""Expose an interactive overlay and grid inspector for one IMAGE and SEGS."""
service_class: ClassVar[type[SimplePreviewSEGSService]] = SimplePreviewSEGSService
publisher_class: ClassVar[type[ComfySegPreviewAssetPublisher]] = (
ComfySegPreviewAssetPublisher
)
RETURN_TYPES = ("SEGS",)
RETURN_NAMES = ("segs",)
OUTPUT_TOOLTIPS = ("The original SEGS passed through without modification.",)
FUNCTION = "preview"
CATEGORY = "SimpleSyrup/Preview"
OUTPUT_NODE = True
DESCRIPTION = (
"Interactively inspects SEGS over the original image or in a selectable grid."
)
SEARCH_ALIASES = ["segs", "regions", "inspect", "overlay", "preview"]
@classmethod
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
"""Declare the source image and matching SEGS inputs."""
return {
"required": {
"image": (
"IMAGE",
{"tooltip": "Original image described by the connected SEGS."},
),
"segs": (
"SEGS",
{"tooltip": "Regions to inspect over the original image."},
),
}
}
def preview(self, image: object, segs: object) -> dict[str, object]:
"""Publish an interactive preview while returning the input SEGS unchanged."""
native_segs = coerce_segs(segs)
document = self.service_class().build(image=image, segs=native_segs)
manifest = self.publisher_class().publish(document)
return {
"ui": {SEG_PREVIEW_UI_KEY: [manifest]},
"result": (segs,),
}
+2
View File
@@ -42,6 +42,7 @@ def get_nodes() -> list[type[object]]:
SeedV3,
SEGSFromSAMOutputV3,
SimpleLoadAnimaV3,
SimplePreviewSEGSV3,
SimpleVAEEncodeV3,
UpscaleLatentFromImageV3,
ViTMatteModelLoaderV3,
@@ -90,6 +91,7 @@ def get_nodes() -> list[type[object]]:
ScaleFactorV3,
SeedV3,
SimpleLoadAnimaV3,
SimplePreviewSEGSV3,
SimpleLoadCheckpointV3,
SimpleVAEEncodeV3,
TagSEGSWithExternalLLMV3,
@@ -40,6 +40,7 @@ from ..nodes.sam_model_loader import SAMModelLoader
from ..nodes.seed import Seed
from ..nodes.segs_from_sam_output import SEGSFromSAMOutput
from ..nodes.simple_load_anima import SimpleLoadAnima
from ..nodes.simple_preview_segs import SimplePreviewSEGS
from ..nodes.vitmatte_model_loader import ViTMatteModelLoader
if TYPE_CHECKING:
@@ -272,6 +273,14 @@ class SEGSFromSAMOutputV3(LegacyNodeV3Adapter):
DISPLAY_NAME = "SEGS from SAM Output"
class SimplePreviewSEGSV3(LegacyNodeV3Adapter):
"""Expose the interactive Simple Preview SEGS node through Comfy v3 only."""
LEGACY_NODE_CLASS = SimplePreviewSEGS
NODE_ID = "SimpleSyrup.SimplePreviewSEGS"
DISPLAY_NAME = "Simple Preview SEGS"
class SeedV3(LegacyNodeV3Adapter):
"""Expose Seed through Comfy v3 only."""
@@ -527,6 +536,7 @@ __all__ = [
"SEGSFromSAMOutputV3",
"SeedV3",
"SimpleLoadAnimaV3",
"SimplePreviewSEGSV3",
"SimpleVAEEncodeV3",
"UpscaleLatentFromImageV3",
"ViTMatteModelLoaderV3",
@@ -9,22 +9,8 @@ from __future__ import annotations
import torch
import torch.nn.functional as functional
from ..domain.segs import NativeSegs, Segment, coerce_segs
_REGION_COLORS: tuple[tuple[float, float, float], ...] = (
(0.95, 0.26, 0.21),
(0.13, 0.59, 0.95),
(0.30, 0.69, 0.31),
(1.00, 0.76, 0.03),
(0.61, 0.15, 0.69),
(1.00, 0.34, 0.13),
(0.00, 0.74, 0.83),
(0.91, 0.12, 0.39),
(0.55, 0.76, 0.29),
(0.40, 0.23, 0.72),
(1.00, 0.60, 0.00),
(0.00, 0.59, 0.53),
)
from ..domain.seg_visualization import build_seg_visualization_plan
from ..domain.segs import NativeSegs
class SAMRegionOverlayRenderer:
@@ -34,14 +20,14 @@ class SAMRegionOverlayRenderer:
"""Return a source-sized IMAGE with translucent masks and stronger edges."""
_validate_image(image)
(segs_height, segs_width), segments = coerce_segs(segs)
plan = build_seg_visualization_plan(segs)
image_height = int(image.shape[1])
image_width = int(image.shape[2])
if (segs_height, segs_width) != (image_height, image_width):
if (plan.source_height, plan.source_width) != (image_height, image_width):
raise ValueError(
"SAM region overlay requires SEGS dimensions to match the image."
)
if not segments:
if not plan.regions:
return image.detach().clone()
color_channels = min(3, int(image.shape[-1]))
@@ -59,11 +45,11 @@ class SAMRegionOverlayRenderer:
boundaries = torch.zeros_like(coverage)
boundary_thickness = max(1, round(max(image_height, image_width) / 1024))
for index, segment in enumerate(segments):
mask = _validated_local_mask(segment, device=device)
region = segment.crop_region
for region_plan in plan.regions:
mask = (region_plan.mask >= 0.5).to(device=device, dtype=torch.float32)
region = region_plan.crop_region
color = torch.tensor(
_REGION_COLORS[index % len(_REGION_COLORS)][:color_channels],
region_plan.color.normalized[:color_channels],
device=device,
dtype=torch.float32,
)
@@ -104,27 +90,6 @@ def _validate_image(image: torch.Tensor) -> None:
raise ValueError("SAM region overlay requires at least one image channel.")
def _validated_local_mask(
segment: Segment,
*,
device: torch.device,
) -> torch.Tensor:
"""Return one binary crop-local mask after validating its SEG geometry."""
mask = segment.cropped_mask
if not isinstance(mask, torch.Tensor):
raise TypeError("SAM region overlay requires tensor SEG masks.")
working = mask.detach().to(device=device, dtype=torch.float32)
if working.ndim == 3 and int(working.shape[0]) == 1:
working = working.squeeze(0)
if working.ndim != 2:
raise ValueError("SAM region overlay requires HW or 1HW SEG masks.")
expected_shape = (segment.crop_region.height, segment.crop_region.width)
if tuple(working.shape) != expected_shape:
raise ValueError("SAM region overlay mask dimensions must match its SEG crop.")
return (working >= 0.5).float()
def _mask_boundary(mask: torch.Tensor, *, thickness: int) -> torch.Tensor:
"""Return the interior boundary band of one binary mask."""
@@ -0,0 +1,92 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Publish interactive SEG preview assets through ComfyUI temporary storage."""
from __future__ import annotations
from importlib import import_module
from typing import Any, Protocol
import torch
from ..services.simple_preview_segs_service import SegPreviewDocument
SEG_PREVIEW_UI_KEY = "simple_syrup_segs_preview"
class SegPreviewAssetPublisher(Protocol):
"""Publish one backend preview document as a JSON-compatible UI payload."""
def publish(self, document: SegPreviewDocument) -> dict[str, object]:
"""Return a frontend-ready manifest with temporary asset references."""
class ComfySegPreviewAssetPublisher:
"""Store preview tensors using ComfyUI's authoritative image helper."""
def publish(self, document: SegPreviewDocument) -> dict[str, object]:
"""Return one versioned manifest referencing a base image and mask atlas."""
comfy_api: Any = import_module("comfy_api.latest")
image_ref = _publish_one(comfy_api, document.image)
atlas_ref = _publish_one(comfy_api, document.atlas)
return {
"version": 1,
"source": {
"width": document.source_width,
"height": document.source_height,
},
"preview": {
"width": document.preview_width,
"height": document.preview_height,
"image": image_ref,
},
"atlas": {
"width": int(document.atlas.shape[2]),
"height": int(document.atlas.shape[1]),
"image": atlas_ref,
},
"regions": [
{
"id": region.region_id,
"index": region.index,
"label": region.label,
"confidence": region.confidence,
"area": region.active_area,
"color": region.color,
"crop": {
"x": region.crop.left,
"y": region.crop.top,
"width": region.crop.width,
"height": region.crop.height,
},
"atlas": {
"x": region.atlas.left,
"y": region.atlas.top,
"width": region.atlas.width,
"height": region.atlas.height,
},
}
for region in document.regions
],
}
def _publish_one(comfy_api: Any, image: torch.Tensor) -> dict[str, str]:
"""Publish one IMAGE tensor and validate ComfyUI's returned reference."""
payload: object = comfy_api.UI.PreviewImage(image).as_dict()
if not isinstance(payload, dict):
raise TypeError("ComfyUI returned an invalid SEG preview asset payload.")
images = payload.get("images")
if not isinstance(images, (list, tuple)) or len(images) != 1:
raise TypeError("ComfyUI must publish exactly one SEG preview asset.")
reference = images[0]
if not isinstance(reference, dict):
raise TypeError("ComfyUI returned an invalid SEG preview image reference.")
required = ("filename", "subfolder", "type")
if any(not isinstance(reference.get(name), str) for name in required):
raise TypeError("ComfyUI SEG preview image reference is incomplete.")
return {name: str(reference[name]) for name in required}
@@ -10,7 +10,7 @@ from typing import Protocol
import torch
from ..domain.segs import NativeSegs, Segment
from ..domain.segs import NativeSegs, Segment, coerce_segment_mask
from ..masking.segs_mask_ops import (
crop_image,
crop_mask,
@@ -25,7 +25,7 @@ from ..runtime.ultralytics_detection import (
)
from ..runtime.ultralytics_loader import UltralyticsDetectorModel
from ..shared.logging import get_logger
from .segs_output_service import coerce_cropped_mask, combined_mask_from_segs
from .segs_output_service import combined_mask_from_segs
LOGGER = get_logger(__name__)
@@ -208,7 +208,7 @@ def _intersect_segs_with_combined_mask(
combined_mask = combined_mask_from_segs(mask_segs)
refined_segments: list[Segment] = []
for segment in segments:
cropped_mask = coerce_cropped_mask(segment)
cropped_mask = coerce_segment_mask(segment)
refinement_mask = crop_mask(combined_mask, segment.crop_region)
refined_segments.append(
Segment(
@@ -234,7 +234,7 @@ def _dilate_cropped_segs(segs: NativeSegs, dilation: int) -> NativeSegs:
return header, tuple(
Segment(
cropped_image=segment.cropped_image,
cropped_mask=dilate_mask(coerce_cropped_mask(segment), dilation),
cropped_mask=dilate_mask(coerce_segment_mask(segment), dilation),
confidence=segment.confidence,
crop_region=segment.crop_region,
bbox=segment.bbox,
@@ -309,7 +309,7 @@ def _build_segment(
]
.detach()
.clone(),
cropped_mask=local_mask.detach().clone(),
cropped_mask=local_mask.unsqueeze(0).detach().clone(),
confidence=max(0.0, min(1.0, float(confidence))),
crop_region=crop_region,
bbox=BoundingBox(
+2 -19
View File
@@ -16,6 +16,7 @@ from ..domain.segs import (
ImpactSegs,
NativeSegs,
Segment,
coerce_segment_mask,
limit_segs,
sort_segs,
to_impact_compatible_segs,
@@ -118,7 +119,7 @@ def combined_mask_from_segs(segs: NativeSegs) -> torch.Tensor:
height, width = header
mask = torch.zeros((height, width), dtype=torch.float32)
for segment in segments:
cropped_mask = coerce_cropped_mask(segment)
cropped_mask = coerce_segment_mask(segment)
region = segment.crop_region
existing = mask[region.top : region.bottom, region.left : region.right]
mask[region.top : region.bottom, region.left : region.right] = torch.maximum(
@@ -126,21 +127,3 @@ def combined_mask_from_segs(segs: NativeSegs) -> torch.Tensor:
cropped_mask.float().cpu(),
)
return mask.clamp(0.0, 1.0)
def coerce_cropped_mask(segment: Segment) -> torch.Tensor:
"""Return a crop-local HW mask tensor for a segment."""
if isinstance(segment.cropped_mask, torch.Tensor):
cropped_mask = segment.cropped_mask.float()
else:
cropped_mask = torch.as_tensor(segment.cropped_mask, dtype=torch.float32)
if cropped_mask.ndim == 3 and int(cropped_mask.shape[0]) == 1:
cropped_mask = cropped_mask.squeeze(0)
if cropped_mask.ndim != 2:
raise ValueError("Segment cropped_mask must be HW shaped.")
expected_shape = (segment.crop_region.height, segment.crop_region.width)
actual_shape = (int(cropped_mask.shape[0]), int(cropped_mask.shape[1]))
if actual_shape != expected_shape:
raise ValueError("Segment cropped_mask must match its crop region.")
return cropped_mask.clamp(0.0, 1.0)
@@ -0,0 +1,233 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Build compact renderer-neutral preview documents from IMAGE and SEGS."""
from __future__ import annotations
from dataclasses import dataclass
from math import ceil, sqrt
import torch
import torch.nn.functional as functional
from ..domain.seg_visualization import (
SegVisualizationPlan,
build_seg_visualization_plan,
)
from ..domain.segs import CropRegion, NativeSegs
from ..masking.segs_mask_ops import validate_single_image
_MAX_PREVIEW_EDGE = 1024
_ATLAS_PIXEL_BUDGET = 4 * 1024 * 1024
_MAX_ATLAS_EDGE = 2048
_ATLAS_PADDING = 1
@dataclass(frozen=True)
class AtlasPlacement:
"""Locate one region mask inside the packed mask atlas."""
left: int
top: int
width: int
height: int
@dataclass(frozen=True)
class SegPreviewRegion:
"""Describe one interactive region and its packed mask geometry."""
region_id: str
index: int
label: str
confidence: float
active_area: int
color: str
crop: CropRegion
atlas: AtlasPlacement
@dataclass(frozen=True)
class SegPreviewDocument:
"""Carry bounded image assets and interaction metadata to the UI adapter."""
source_width: int
source_height: int
preview_width: int
preview_height: int
image: torch.Tensor
atlas: torch.Tensor
regions: tuple[SegPreviewRegion, ...]
class SimplePreviewSEGSService:
"""Create one compact interactive-preview document without ComfyUI IO."""
def build(self, *, image: object, segs: NativeSegs) -> SegPreviewDocument:
"""Return a bounded base image, packed masks, and deterministic metadata."""
source_image = validate_single_image(image, "Simple Preview SEGS")
plan = build_seg_visualization_plan(segs)
image_height = int(source_image.shape[1])
image_width = int(source_image.shape[2])
if (plan.source_height, plan.source_width) != (image_height, image_width):
raise ValueError(
"Simple Preview SEGS requires IMAGE and SEGS dimensions to match."
)
preview_width, preview_height = _fit_dimensions(
image_width,
image_height,
_MAX_PREVIEW_EDGE,
)
preview_image = _resize_image(
source_image,
width=preview_width,
height=preview_height,
)
atlas, regions = _build_atlas(plan)
return SegPreviewDocument(
source_width=image_width,
source_height=image_height,
preview_width=preview_width,
preview_height=preview_height,
image=preview_image,
atlas=atlas,
regions=regions,
)
def _build_atlas(
plan: SegVisualizationPlan,
) -> tuple[torch.Tensor, tuple[SegPreviewRegion, ...]]:
"""Pack every crop-local mask into one bounded RGB atlas."""
if not plan.regions:
return torch.zeros((1, 1, 1, 3), dtype=torch.float32), ()
total_crop_area = sum(
region.crop_region.width * region.crop_region.height for region in plan.regions
)
preview_scale = min(
1.0,
_MAX_PREVIEW_EDGE / max(plan.source_width, plan.source_height),
)
budget_scale = sqrt(_ATLAS_PIXEL_BUDGET / max(1, total_crop_area))
scale = min(preview_scale, budget_scale)
while True:
sizes = tuple(
(
max(1, round(region.crop_region.width * scale)),
max(1, round(region.crop_region.height * scale)),
)
for region in plan.regions
)
placements, atlas_width, atlas_height = _pack_rectangles(sizes)
if atlas_width <= _MAX_ATLAS_EDGE and atlas_height <= _MAX_ATLAS_EDGE:
break
scale *= 0.85
if scale < 1.0 / max(plan.source_width, plan.source_height):
raise ValueError("Simple Preview SEGS could not pack the region masks.")
atlas = torch.zeros((atlas_height, atlas_width), dtype=torch.float32)
metadata: list[SegPreviewRegion] = []
for visual_region, placement in zip(plan.regions, placements, strict=True):
resized = (
functional.interpolate(
visual_region.mask.unsqueeze(0).unsqueeze(0).float(),
size=(placement.height, placement.width),
mode="nearest-exact",
)
.squeeze(0)
.squeeze(0)
)
atlas[
placement.top : placement.top + placement.height,
placement.left : placement.left + placement.width,
] = resized
metadata.append(
SegPreviewRegion(
region_id=visual_region.region_id,
index=visual_region.index,
label=visual_region.segment.label,
confidence=float(visual_region.segment.confidence),
active_area=visual_region.active_area,
color=visual_region.color.css,
crop=visual_region.crop_region,
atlas=placement,
)
)
return atlas.unsqueeze(0).unsqueeze(-1).expand(-1, -1, -1, 3), tuple(metadata)
def _pack_rectangles(
sizes: tuple[tuple[int, int], ...],
) -> tuple[tuple[AtlasPlacement, ...], int, int]:
"""Pack ordered rectangles into deterministic height-sorted shelves."""
padded_area = sum(
(width + _ATLAS_PADDING) * (height + _ATLAS_PADDING) for width, height in sizes
)
widest = max(width for width, _height in sizes)
shelf_width = min(
_MAX_ATLAS_EDGE,
max(widest, ceil(sqrt(max(1, padded_area)))),
)
indexed = sorted(
enumerate(sizes),
key=lambda item: (-item[1][1], -item[1][0], item[0]),
)
placements: list[AtlasPlacement | None] = [None] * len(sizes)
x = 0
y = 0
shelf_height = 0
used_width = 1
for index, (width, height) in indexed:
if x > 0 and x + width > shelf_width:
y += shelf_height + _ATLAS_PADDING
x = 0
shelf_height = 0
placements[index] = AtlasPlacement(x, y, width, height)
used_width = max(used_width, x + width)
x += width + _ATLAS_PADDING
shelf_height = max(shelf_height, height)
used_height = max(1, y + shelf_height)
return (
tuple(placement for placement in placements if placement is not None),
used_width,
used_height,
)
def _fit_dimensions(width: int, height: int, maximum: int) -> tuple[int, int]:
"""Fit image dimensions inside one maximum edge without upscaling."""
if max(width, height) <= maximum:
return width, height
scale = maximum / max(width, height)
return max(1, round(width * scale)), max(1, round(height * scale))
def _resize_image(
image: torch.Tensor,
*,
width: int,
height: int,
) -> torch.Tensor:
"""Resize one BHWC image while preserving its channel count."""
if image.shape[1:3] == (height, width):
return image.detach().cpu().clone()
return (
functional.interpolate(
image.detach().cpu().movedim(-1, 1).float(),
size=(height, width),
mode="bilinear",
align_corners=False,
)
.movedim(1, -1)
.clamp(0.0, 1.0)
)
+1
View File
@@ -49,6 +49,7 @@ BASE_NODE_IDS = [
"SimpleSyrup.ScaleFactor",
"SimpleSyrup.Seed",
"SimpleSyrup.SimpleLoadAnima",
"SimpleSyrup.SimplePreviewSEGS",
"SimpleSyrup.SimpleLoadCheckpoint",
"SimpleSyrup.SimpleVAEEncode",
"SimpleSyrup.TagSEGSWithExternalLLM",
+143
View File
@@ -0,0 +1,143 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tests for ComfyUI temporary-asset publication for SEG previews."""
from __future__ import annotations
from types import SimpleNamespace
from typing import ClassVar
import pytest
import torch
from pytest import MonkeyPatch
from simple_syrup.domain.segs import CropRegion
from simple_syrup.runtime.seg_preview_assets import ComfySegPreviewAssetPublisher
from simple_syrup.services.simple_preview_segs_service import (
AtlasPlacement,
SegPreviewDocument,
SegPreviewRegion,
)
def test_publisher_stores_both_assets_and_builds_versioned_manifest(
monkeypatch: MonkeyPatch,
) -> None:
"""The runtime adapter translates Comfy references without leaking tensors."""
class _PreviewImage:
"""Return one distinct Comfy image reference per published tensor."""
calls: ClassVar[list[torch.Tensor]] = []
def __init__(self, image: torch.Tensor) -> None:
self.calls.append(image)
def as_dict(self) -> dict[str, object]:
"""Return the reference for the most recent call."""
index = len(self.calls)
return {
"images": [
{
"filename": f"asset-{index}.png",
"subfolder": "preview",
"type": "temp",
}
]
}
fake_api = SimpleNamespace(UI=SimpleNamespace(PreviewImage=_PreviewImage))
monkeypatch.setattr(
"simple_syrup.runtime.seg_preview_assets.import_module",
lambda _name: fake_api,
)
document = _document()
manifest = ComfySegPreviewAssetPublisher().publish(document)
assert _PreviewImage.calls == [document.image, document.atlas]
assert manifest["version"] == 1
assert manifest["preview"] == {
"width": 4,
"height": 3,
"image": {
"filename": "asset-1.png",
"subfolder": "preview",
"type": "temp",
},
}
assert manifest["atlas"] == {
"width": 2,
"height": 1,
"image": {
"filename": "asset-2.png",
"subfolder": "preview",
"type": "temp",
},
}
assert manifest["regions"] == [
{
"id": "seg-0001",
"index": 0,
"label": "subject",
"confidence": 0.9,
"area": 6,
"color": "#f24236",
"crop": {"x": 1, "y": 0, "width": 2, "height": 3},
"atlas": {"x": 0, "y": 0, "width": 2, "height": 1},
}
]
def test_publisher_rejects_malformed_comfy_image_reference(
monkeypatch: MonkeyPatch,
) -> None:
"""Invalid host output fails explicitly before the frontend sees it."""
class _InvalidPreviewImage:
"""Return a payload missing Comfy's required image reference fields."""
def __init__(self, _image: torch.Tensor) -> None:
pass
def as_dict(self) -> dict[str, object]:
"""Return one intentionally incomplete reference."""
return {"images": [{"filename": "asset.png"}]}
fake_api = SimpleNamespace(UI=SimpleNamespace(PreviewImage=_InvalidPreviewImage))
monkeypatch.setattr(
"simple_syrup.runtime.seg_preview_assets.import_module",
lambda _name: fake_api,
)
with pytest.raises(TypeError, match="reference is incomplete"):
ComfySegPreviewAssetPublisher().publish(_document())
def _document() -> SegPreviewDocument:
"""Return one small document with stable transport metadata."""
return SegPreviewDocument(
source_width=4,
source_height=3,
preview_width=4,
preview_height=3,
image=torch.zeros((1, 3, 4, 3)),
atlas=torch.ones((1, 1, 2, 3)),
regions=(
SegPreviewRegion(
region_id="seg-0001",
index=0,
label="subject",
confidence=0.9,
active_area=6,
color="#f24236",
crop=CropRegion(1, 0, 3, 3),
atlas=AtlasPlacement(0, 0, 2, 1),
),
),
)
+54
View File
@@ -0,0 +1,54 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tests for shared SEG visualization planning."""
from __future__ import annotations
import pytest
import torch
from simple_syrup.domain.seg_visualization import build_seg_visualization_plan
from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment
def test_plan_assigns_stable_ids_colors_and_normalized_masks() -> None:
"""The shared plan owns deterministic visualization identity and geometry."""
first = _segment(CropRegion(1, 2, 4, 4), torch.ones((1, 2, 3)), "face")
second_mask = torch.tensor([[1.0, 0.0], [1.0, 0.0]])
second = _segment(CropRegion(5, 1, 7, 3), second_mask, "hand")
plan = build_seg_visualization_plan(((8, 10), (first, second)))
assert (plan.source_height, plan.source_width) == (8, 10)
assert [region.region_id for region in plan.regions] == [
"seg-0001",
"seg-0002",
]
assert [region.color.css for region in plan.regions] == ["#f24236", "#2196f3"]
assert plan.regions[0].mask.shape == (2, 3)
assert plan.regions[1].active_area == 2
def test_plan_rejects_crop_outside_declared_source() -> None:
"""Invalid source geometry fails before any renderer consumes it."""
segment = _segment(CropRegion(7, 7, 10, 10), torch.ones((3, 3)), "bad")
with pytest.raises(ValueError, match="fit inside"):
build_seg_visualization_plan(((8, 8), (segment,)))
def _segment(region: CropRegion, mask: torch.Tensor, label: str) -> Segment:
"""Create one visualization-ready segment."""
return Segment(
cropped_image=None,
cropped_mask=mask,
confidence=0.75,
crop_region=region,
bbox=BoundingBox(*region),
label=label,
)
+2 -2
View File
@@ -72,7 +72,7 @@ def test_service_downscales_the_segmentation_guide_without_upscaling_source() ->
segment = segs[1][0]
assert segment.bbox == (64, 32, 128, 64)
assert segment.crop_region == (64, 32, 128, 64)
assert cast(torch.Tensor, segment.cropped_mask).shape == (32, 64)
assert cast(torch.Tensor, segment.cropped_mask).shape == (1, 32, 64)
assert segment.confidence == 0.8
assert segment.label == "segment_001"
@@ -164,7 +164,7 @@ def test_service_expands_retained_mask_crops_to_source_resolution() -> None:
segment = result.segs[1][0]
assert segment.crop_region == (512, 256, 1024, 512)
assert cast(torch.Tensor, segment.cropped_mask).shape == (256, 512)
assert cast(torch.Tensor, segment.cropped_mask).shape == (1, 256, 512)
@pytest.mark.parametrize("resolution", (0, 65, 127))
+24 -1
View File
@@ -8,7 +8,13 @@ from __future__ import annotations
import torch
from simple_syrup.domain.segs import BoundingBox, CropRegion, NativeSegs, Segment
from simple_syrup.domain.segs import (
BoundingBox,
CropRegion,
NativeSegs,
Segment,
coerce_segment_mask,
)
from simple_syrup.services.segs_output_service import (
CombinedSegsResult,
finalize_detector_segs_output,
@@ -79,6 +85,23 @@ def test_finalization_supports_largest_size_for_mask_derived_segs() -> None:
assert builder.seen_labels == [["large-low"]]
def test_cropped_mask_coercion_accepts_hw_and_singleton_bhw() -> None:
"""Shared mask coercion preserves existing HW and singleton-BHW behavior."""
hw_segment = _segment("hw", CropRegion(0, 0, 3, 2), 1.0)
bhw_segment = Segment(
cropped_image=None,
cropped_mask=torch.ones((1, 2, 3)),
confidence=1.0,
crop_region=CropRegion(0, 0, 3, 2),
bbox=BoundingBox(0, 0, 3, 2),
label="bhw",
)
assert coerce_segment_mask(hw_segment).shape == (2, 3)
assert coerce_segment_mask(bhw_segment).shape == (2, 3)
class _RecordingBuilder:
"""Combined-result builder test double."""
+79
View File
@@ -0,0 +1,79 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tests for the Simple Preview SEGS ComfyUI node."""
from __future__ import annotations
from typing import ClassVar
import torch
from pytest import MonkeyPatch
from simple_syrup.nodes.simple_preview_segs import SimplePreviewSEGS
from simple_syrup.services.simple_preview_segs_service import SegPreviewDocument
def test_node_declares_terminal_passthrough_preview_contract() -> None:
"""The inspector remains insertable inline and executable as a terminal node."""
inputs = SimplePreviewSEGS.INPUT_TYPES()["required"]
assert tuple(inputs) == ("image", "segs")
assert SimplePreviewSEGS.RETURN_TYPES == ("SEGS",)
assert SimplePreviewSEGS.RETURN_NAMES == ("segs",)
assert SimplePreviewSEGS.OUTPUT_NODE is True
def test_node_publishes_manifest_and_returns_original_segs_object(
monkeypatch: MonkeyPatch,
) -> None:
"""Preview transport never replaces or mutates the connected SEGS payload."""
segs: object = ((4, 5), [])
document = SegPreviewDocument(
source_width=5,
source_height=4,
preview_width=5,
preview_height=4,
image=torch.zeros((1, 4, 5, 3)),
atlas=torch.zeros((1, 1, 1, 3)),
regions=(),
)
class _Service:
"""Return the fixed preview document."""
def build(self, *, image: object, segs: object) -> SegPreviewDocument:
"""Validate inputs before returning the document."""
assert isinstance(image, torch.Tensor)
assert segs == ((4, 5), ())
return document
class _Publisher:
"""Return one deterministic frontend manifest."""
published: ClassVar[list[SegPreviewDocument]] = []
def publish(self, value: SegPreviewDocument) -> dict[str, object]:
"""Record and publish the document."""
self.published.append(value)
return {"version": 1, "regions": []}
node = SimplePreviewSEGS()
monkeypatch.setattr(SimplePreviewSEGS, "service_class", _Service)
monkeypatch.setattr(SimplePreviewSEGS, "publisher_class", _Publisher)
result = node.preview(image=torch.zeros((1, 4, 5, 3)), segs=segs)
node_result = result["result"]
assert isinstance(node_result, tuple)
assert node_result == (segs,)
assert node_result[0] is segs
assert result["ui"] == {
"simple_syrup_segs_preview": [{"version": 1, "regions": []}]
}
assert _Publisher.published == [document]
+75
View File
@@ -0,0 +1,75 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tests for compact interactive SEGS preview document creation."""
from __future__ import annotations
import pytest
import torch
from simple_syrup.domain.segs import BoundingBox, CropRegion, Segment
from simple_syrup.services.simple_preview_segs_service import SimplePreviewSEGSService
def test_service_builds_bounded_image_and_non_overlapping_mask_atlas() -> None:
"""One compact document retains source geometry and every region mask."""
image = torch.rand((1, 1200, 2400, 3), generator=torch.Generator().manual_seed(8))
first = _segment(CropRegion(0, 0, 600, 400), "subject")
second = _segment(CropRegion(1000, 500, 1600, 1100), "clothing")
document = SimplePreviewSEGSService().build(
image=image,
segs=((1200, 2400), (first, second)),
)
assert (document.preview_width, document.preview_height) == (1024, 512)
assert document.image.shape == (1, 512, 1024, 3)
assert document.atlas.ndim == 4
assert int(document.atlas.shape[-1]) == 3
assert [region.label for region in document.regions] == ["subject", "clothing"]
first_atlas, second_atlas = (region.atlas for region in document.regions)
assert (
first_atlas.left + first_atlas.width <= second_atlas.left
or second_atlas.left + second_atlas.width <= first_atlas.left
or first_atlas.top + first_atlas.height <= second_atlas.top
or second_atlas.top + second_atlas.height <= first_atlas.top
)
def test_service_returns_minimal_assets_for_empty_segs() -> None:
"""Empty SEGS remain inspectable without special frontend transport."""
document = SimplePreviewSEGSService().build(
image=torch.zeros((1, 8, 12, 3)),
segs=((8, 12), ()),
)
assert document.image.shape == (1, 8, 12, 3)
assert document.atlas.shape == (1, 1, 1, 3)
assert document.regions == ()
def test_service_rejects_image_and_segs_size_mismatch() -> None:
"""Hit testing cannot proceed against ambiguous source coordinates."""
with pytest.raises(ValueError, match="dimensions to match"):
SimplePreviewSEGSService().build(
image=torch.zeros((1, 8, 8, 3)),
segs=((7, 8), ()),
)
def _segment(region: CropRegion, label: str) -> Segment:
"""Create one filled region for atlas tests."""
return Segment(
cropped_image=None,
cropped_mask=torch.ones((region.height, region.width)),
confidence=0.8,
crop_region=region,
bbox=BoundingBox(*region),
label=label,
)
+779 -2
View File
@@ -895,9 +895,785 @@ function isMaskBatchNode(candidate) {
return node.constructor?.comfyClass === LOAD_MASK_BATCH_NODE_ID && typeof node.addWidget === "function";
}
// web/src/interactiveInspector.ts
var InteractiveInspectorController = class {
constructor(view, parse, logger = console) {
this.view = view;
this.parse = parse;
this.logger = logger;
}
view;
parse;
logger;
version = 0;
disposed = false;
committed;
/** Parse and asynchronously display one execution output. */
update(output) {
if (this.disposed) return;
const version = ++this.version;
let document2;
try {
document2 = this.parse(output);
} catch (error) {
this.fail(error);
return;
}
if (!document2) return;
this.view.setLoading();
void this.view.prepare(document2).then((prepared) => {
if (this.disposed || version !== this.version) {
prepared.dispose();
return;
}
this.committed?.dispose();
this.committed = prepared;
prepared.commit();
}).catch((error) => {
if (!this.disposed && version === this.version) this.fail(error);
});
}
/** Release committed state and prevent pending work from publishing. */
dispose() {
if (this.disposed) return;
this.disposed = true;
this.version += 1;
this.committed?.dispose();
this.committed = void 0;
this.view.dispose();
}
fail(error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Could not display Simple Preview SEGS: ${message}`, error);
this.view.showError(message);
}
};
// web/src/imageViewport.ts
var ImageViewport = class {
constructor(source, display) {
this.source = source;
this.display = display;
}
source;
display;
/** Convert browser pointer coordinates into source-image coordinates. */
sourcePoint(clientX, clientY, bounds) {
const normalizedX = clamp((clientX - bounds.left) / Math.max(1, bounds.width));
const normalizedY = clamp((clientY - bounds.top) / Math.max(1, bounds.height));
return {
x: normalizedX * this.source.width,
y: normalizedY * this.source.height
};
}
/** Project one source rectangle into the preview canvas. */
displayRectangle(sourceRectangle) {
const scaleX = this.display.width / this.source.width;
const scaleY = this.display.height / this.source.height;
return {
x: sourceRectangle.x * scaleX,
y: sourceRectangle.y * scaleY,
width: sourceRectangle.width * scaleX,
height: sourceRectangle.height * scaleY
};
}
};
function clamp(value) {
return Math.max(0, Math.min(1, value));
}
// web/src/maskAtlas.ts
var MaskAtlas = class {
constructor(document2, pixels) {
this.document = document2;
this.pixels = pixels;
const expected = document2.atlas.width * document2.atlas.height * 4;
if (pixels.length !== expected) {
throw new Error("Simple Preview SEGS atlas pixels do not match its dimensions.");
}
}
document;
pixels;
/** Return every mask containing a source point, most specific first. */
hitsAt(sourceX, sourceY) {
return this.document.regions.filter((region) => this.contains(region, sourceX, sourceY)).sort((left, right) => left.area - right.area || left.index - right.index);
}
/** Return one colored RGBA image for a packed region mask. */
coloredMask(region) {
const color = parseColor(region.color);
const data = new Uint8ClampedArray(region.atlas.width * region.atlas.height * 4);
for (let y = 0; y < region.atlas.height; y += 1) {
for (let x = 0; x < region.atlas.width; x += 1) {
const atlasOffset = this.offset(region.atlas.x + x, region.atlas.y + y);
const mask = this.pixels[atlasOffset] ?? 0;
const outputOffset = (y * region.atlas.width + x) * 4;
data[outputOffset] = color[0];
data[outputOffset + 1] = color[1];
data[outputOffset + 2] = color[2];
data[outputOffset + 3] = mask;
}
}
return new ImageData(data, region.atlas.width, region.atlas.height);
}
contains(region, sourceX, sourceY) {
const crop = region.crop;
if (sourceX < crop.x || sourceY < crop.y || sourceX >= crop.x + crop.width || sourceY >= crop.y + crop.height) {
return false;
}
const localX = Math.min(
region.atlas.width - 1,
Math.floor((sourceX - crop.x) / crop.width * region.atlas.width)
);
const localY = Math.min(
region.atlas.height - 1,
Math.floor((sourceY - crop.y) / crop.height * region.atlas.height)
);
return (this.pixels[this.offset(region.atlas.x + localX, region.atlas.y + localY)] ?? 0) >= 128;
}
offset(x, y) {
return (y * this.document.atlas.width + x) * 4;
}
};
function maskAtlasFromImage(previewDocument, image) {
const canvas = globalThis.document.createElement("canvas");
canvas.width = previewDocument.atlas.width;
canvas.height = previewDocument.atlas.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Simple Preview SEGS could not read its mask atlas.");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return new MaskAtlas(
previewDocument,
context.getImageData(0, 0, canvas.width, canvas.height).data
);
}
function parseColor(value) {
return [
Number.parseInt(value.slice(1, 3), 16),
Number.parseInt(value.slice(3, 5), 16),
Number.parseInt(value.slice(5, 7), 16)
];
}
// web/src/segPreviewTypes.ts
var SEG_PREVIEW_OUTPUT_KEY = "simple_syrup_segs_preview";
function parseSegPreviewDocument(output) {
if (!isRecord(output)) return void 0;
const candidates = output[SEG_PREVIEW_OUTPUT_KEY];
if (!Array.isArray(candidates) || candidates.length === 0) return void 0;
const candidate = candidates[candidates.length - 1];
if (!isRecord(candidate) || candidate.version !== 1) {
throw new Error("Simple Preview SEGS received an unsupported preview payload.");
}
const source = parseDimensions(candidate.source, "source");
const preview = parseAssetDimensions(candidate.preview, "preview");
const atlas = parseAssetDimensions(candidate.atlas, "atlas");
if (!Array.isArray(candidate.regions)) {
throw new Error("Simple Preview SEGS regions must be a list.");
}
const regions = candidate.regions.map(parseRegion);
return { version: 1, source, preview, atlas, regions };
}
function previewAssetUrl(reference, apiURL = (path) => path) {
const query = new URLSearchParams({
filename: reference.filename,
subfolder: reference.subfolder,
type: reference.type
});
return apiURL(`/view?${query.toString()}`);
}
function parseRegion(value, index) {
if (!isRecord(value)) {
throw new Error(
`Simple Preview SEGS region ${String(index + 1)} must be an object.`
);
}
const id = stringValue(value.id, "region id");
const label = stringValue(value.label, "region label");
const color = stringValue(value.color, "region color");
if (!/^#[0-9a-f]{6}$/i.test(color)) {
throw new Error(`Simple Preview SEGS region '${id}' has an invalid color.`);
}
return {
id,
index: integerValue(value.index, "region index", 0),
label,
confidence: numberValue(value.confidence, "region confidence", 0),
area: integerValue(value.area, "region area", 0),
color,
crop: parseRectangle(value.crop, `region '${id}' crop`),
atlas: parseRectangle(value.atlas, `region '${id}' atlas`)
};
}
function parseAssetDimensions(value, name) {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} must be an object.`);
}
return {
...parseDimensions(value, name),
image: parseImageReference(value.image, name)
};
}
function parseDimensions(value, name) {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} dimensions must be an object.`);
}
return {
width: integerValue(value.width, `${name} width`, 1),
height: integerValue(value.height, `${name} height`, 1)
};
}
function parseRectangle(value, name) {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} must be an object.`);
}
return {
x: integerValue(value.x, `${name} x`, 0),
y: integerValue(value.y, `${name} y`, 0),
width: integerValue(value.width, `${name} width`, 1),
height: integerValue(value.height, `${name} height`, 1)
};
}
function parseImageReference(value, name) {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} image reference is invalid.`);
}
const type = stringValue(value.type, `${name} image type`);
if (type !== "input" && type !== "output" && type !== "temp") {
throw new Error(`Simple Preview SEGS ${name} image type is invalid.`);
}
return {
filename: stringValue(value.filename, `${name} image filename`),
subfolder: stringValue(value.subfolder, `${name} image subfolder`),
type
};
}
function integerValue(value, name, minimum) {
if (!Number.isInteger(value) || value < minimum) {
throw new Error(
`Simple Preview SEGS ${name} must be at least ${String(minimum)}.`
);
}
return value;
}
function numberValue(value, name, minimum) {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
throw new Error(
`Simple Preview SEGS ${name} must be at least ${String(minimum)}.`
);
}
return value;
}
function stringValue(value, name) {
if (typeof value !== "string") {
throw new Error(`Simple Preview SEGS ${name} must be text.`);
}
return value;
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// web/src/selectionModel.ts
var SelectionModel = class {
hovered = null;
selected = null;
candidates = [];
subscribers = /* @__PURE__ */ new Set();
/** Subscribe to selection changes and receive the current state immediately. */
subscribe(subscriber) {
this.subscribers.add(subscriber);
subscriber(this.state());
return () => this.subscribers.delete(subscriber);
}
/** Replace the hover target and all regions currently beneath the pointer. */
hover(candidates) {
this.candidates = [...candidates];
this.hovered = candidates[0] ?? null;
this.publish();
}
/** Clear transient pointer state without changing the pinned region. */
clearHover() {
this.candidates = [];
this.hovered = null;
this.publish();
}
/** Pin one region, or clear the pinned selection with null. */
select(id) {
this.selected = id;
this.publish();
}
/** Cycle through overlapping pointer candidates and pin the result. */
selectNextCandidate() {
if (this.candidates.length === 0) return this.selected;
const currentIndex = this.selected === null ? -1 : this.candidates.indexOf(this.selected);
const next = this.candidates[(currentIndex + 1) % this.candidates.length] ?? null;
this.select(next);
return next;
}
/** Return immutable selection state for rendering or tests. */
state() {
return {
hovered: this.hovered,
selected: this.selected,
candidates: [...this.candidates],
active: this.hovered ?? this.selected
};
}
publish() {
const state = this.state();
for (const subscriber of this.subscribers) subscriber(state);
}
};
// web/src/segPreviewInspector.ts
var SegPreviewInspector = class {
constructor(apiURL = (path) => path, loadImage = loadPreviewImage) {
this.apiURL = apiURL;
this.loadImage = loadImage;
installStyles();
this.element.className = "ss-segs-preview";
this.body.className = "ss-segs-preview__body";
this.status.className = "ss-segs-preview__status";
this.status.setAttribute("aria-live", "polite");
const toolbar = document.createElement("nav");
toolbar.className = "ss-segs-preview__toolbar";
toolbar.setAttribute("aria-label", "SEGS preview mode");
toolbar.append(this.overlayButton, this.gridButton);
this.overlayButton.addEventListener("click", () => {
this.setMode("overlay");
});
this.gridButton.addEventListener("click", () => {
this.setMode("grid");
});
this.element.append(toolbar, this.body, this.status);
this.updateModeButtons();
this.showMessage("Run the workflow to inspect SEGS.");
}
apiURL;
loadImage;
element = document.createElement("section");
body = document.createElement("div");
status = document.createElement("div");
overlayButton = modeButton("Overlay");
gridButton = modeButton("Grid");
mode = "overlay";
committed;
unsubscribeSelection;
highlightCanvas;
focusContainer;
gridButtons = /* @__PURE__ */ new Map();
/** Show a native loading state while execution assets are decoded. */
setLoading() {
this.showMessage("Loading SEGS preview\u2026");
}
/** Load source and atlas assets without publishing partial state. */
async prepare(document2) {
const [image, atlasImage] = await Promise.all([
this.loadImage(previewAssetUrl(document2.preview.image, this.apiURL)),
this.loadImage(previewAssetUrl(document2.atlas.image, this.apiURL))
]);
const atlas = maskAtlasFromImage(document2, atlasImage);
let disposed = false;
return {
commit: () => {
if (!disposed) this.commit(document2, image, atlas);
},
dispose: () => {
disposed = true;
}
};
}
/** Replace the inspector with an actionable failure message. */
showError(message) {
this.showMessage(message, true);
}
/** Release node-owned DOM and interaction subscriptions. */
dispose() {
this.unsubscribeSelection?.();
this.unsubscribeSelection = void 0;
this.committed = void 0;
this.body.replaceChildren();
this.status.textContent = "";
}
commit(document2, image, atlas) {
this.unsubscribeSelection?.();
const selection = new SelectionModel();
const masks = new Map(
document2.regions.map((region) => [
region.id,
maskCanvas(atlas, region)
])
);
this.committed = {
document: document2,
image,
atlas,
viewport: new ImageViewport(document2.source, document2.preview),
masks,
selection
};
this.unsubscribeSelection = selection.subscribe((state) => {
this.renderSelection(state);
});
this.renderMode();
}
setMode(mode) {
if (this.mode === mode) return;
this.mode = mode;
this.updateModeButtons();
this.renderMode();
}
updateModeButtons() {
this.overlayButton.setAttribute(
"aria-pressed",
String(this.mode === "overlay")
);
this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid"));
}
renderMode() {
this.highlightCanvas = void 0;
this.focusContainer = void 0;
this.gridButtons.clear();
if (!this.committed) return;
this.body.replaceChildren(
this.mode === "overlay" ? this.overlayView() : this.gridView()
);
this.renderSelection(this.committed.selection.state());
}
overlayView() {
const committed = required(this.committed);
const container = document.createElement("div");
container.className = "ss-segs-preview__overlay-view";
const stack = document.createElement("div");
stack.className = "ss-segs-preview__canvas-stack";
const base = sizedCanvas(committed.document.preview);
const highlight = sizedCanvas(committed.document.preview);
highlight.className = "ss-segs-preview__highlight";
this.highlightCanvas = highlight;
const context = context2d(base);
context.drawImage(
committed.image,
0,
0,
committed.document.preview.width,
committed.document.preview.height
);
context.globalAlpha = 0.38;
for (const region of committed.document.regions) {
drawRegionMask(context, committed, region);
}
context.globalAlpha = 1;
highlight.addEventListener("pointermove", (event) => {
const point = committed.viewport.sourcePoint(
event.clientX,
event.clientY,
highlight.getBoundingClientRect()
);
const hits = committed.atlas.hitsAt(point.x, point.y);
committed.selection.hover(hits.map((region) => region.id));
});
highlight.addEventListener("pointerleave", () => {
committed.selection.clearHover();
});
highlight.addEventListener("click", () => {
committed.selection.selectNextCandidate();
});
stack.append(base, highlight);
this.focusContainer = document.createElement("div");
this.focusContainer.className = "ss-segs-preview__focus";
container.append(stack, this.focusContainer);
return container;
}
gridView() {
const committed = required(this.committed);
const grid = document.createElement("div");
grid.className = "ss-segs-preview__grid";
grid.setAttribute("role", "listbox");
if (committed.document.regions.length === 0) {
const empty = document.createElement("div");
empty.className = "ss-segs-preview__empty";
empty.textContent = "No SEGS to display.";
grid.append(empty);
return grid;
}
for (const region of committed.document.regions) {
const card = document.createElement("button");
card.type = "button";
card.className = "ss-segs-preview__card";
card.setAttribute("role", "option");
card.dataset.regionId = region.id;
const canvas = regionPreviewCanvas(committed, region, 160, 132);
const label = document.createElement("span");
label.textContent = regionTitle(region);
card.append(canvas, label);
card.addEventListener("pointerenter", () => {
committed.selection.hover([region.id]);
});
card.addEventListener("pointerleave", () => {
committed.selection.clearHover();
});
card.addEventListener("click", () => {
committed.selection.select(region.id);
});
this.gridButtons.set(region.id, card);
grid.append(card);
}
return grid;
}
renderSelection(state) {
const committed = this.committed;
if (!committed) return;
const active = state.active ? committed.document.regions.find((region) => region.id === state.active) : void 0;
if (this.highlightCanvas) {
const context = context2d(this.highlightCanvas);
context.clearRect(
0,
0,
this.highlightCanvas.width,
this.highlightCanvas.height
);
if (active) {
context.save();
context.globalAlpha = 0.9;
context.shadowColor = "rgba(255, 255, 255, 0.95)";
context.shadowBlur = 8;
drawRegionMask(context, committed, active);
context.restore();
}
}
for (const [id, button] of this.gridButtons) {
const selected = id === state.selected;
const highlighted = id === active?.id;
button.setAttribute("aria-selected", String(selected));
button.dataset.highlighted = String(highlighted);
}
this.renderFocus(active);
if (!active) {
this.status.textContent = `${String(committed.document.regions.length)} regions`;
} else if (state.candidates.length > 1) {
this.status.textContent = `${regionTitle(active)} \xB7 ${String(state.candidates.length)} overlapping regions \xB7 click to cycle`;
} else {
this.status.textContent = regionTitle(active);
}
}
renderFocus(region) {
if (!this.focusContainer || !this.committed) return;
if (!region) {
this.focusContainer.replaceChildren();
return;
}
const title = document.createElement("strong");
title.textContent = regionTitle(region);
const details = document.createElement("span");
details.textContent = `${String(Math.round(region.confidence * 100))}% confidence \xB7 ${region.area.toLocaleString()} px`;
this.focusContainer.replaceChildren(
regionPreviewCanvas(this.committed, region, 320, 220),
title,
details
);
}
showMessage(message, error = false) {
const content = document.createElement("div");
content.className = "ss-segs-preview__message";
content.dataset.error = String(error);
content.textContent = message;
this.body.replaceChildren(content);
this.status.textContent = "";
}
};
function drawRegionMask(context, committed, region) {
const placement = committed.viewport.displayRectangle(region.crop);
const mask = committed.masks.get(region.id);
if (!mask) return;
context.drawImage(
mask,
placement.x,
placement.y,
placement.width,
placement.height
);
}
function regionPreviewCanvas(committed, region, maximumWidth, maximumHeight) {
const aspect = region.crop.width / region.crop.height;
const width = Math.max(80, Math.min(maximumWidth, Math.round(maximumHeight * aspect)));
const height = Math.max(64, Math.min(maximumHeight, Math.round(width / aspect)));
const canvas = sizedCanvas({ width, height });
const context = context2d(canvas);
const source = committed.viewport.displayRectangle(region.crop);
context.drawImage(
committed.image,
source.x,
source.y,
source.width,
source.height,
0,
0,
width,
height
);
context.globalAlpha = 0.45;
const mask = committed.masks.get(region.id);
if (mask) context.drawImage(mask, 0, 0, width, height);
context.globalAlpha = 1;
return canvas;
}
function maskCanvas(atlas, region) {
const canvas = sizedCanvas(region.atlas);
context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0);
return canvas;
}
function sizedCanvas(size) {
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
return canvas;
}
function context2d(canvas) {
const context = canvas.getContext("2d");
if (!context) throw new Error("Simple Preview SEGS requires canvas rendering.");
return context;
}
function regionTitle(region) {
const label = region.label.trim();
return `${String(region.index + 1)}. ${label || "region"}`;
}
function modeButton(label) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
return button;
}
function required(value) {
if (!value) throw new Error("Simple Preview SEGS has no committed document.");
return value;
}
function loadPreviewImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => {
resolve(image);
}, { once: true });
image.addEventListener(
"error",
() => {
reject(new Error("Simple Preview SEGS could not load a preview asset."));
},
{ once: true }
);
image.src = url;
});
}
var stylesInstalled = false;
function installStyles() {
if (stylesInstalled) return;
const style = document.createElement("style");
style.dataset.simpleSyrupSegsPreview = "true";
style.textContent = `
.ss-segs-preview { box-sizing: border-box; width: 100%; min-height: 360px; color: var(--fg-color, #ddd); font: 12px sans-serif; }
.ss-segs-preview * { box-sizing: border-box; }
.ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; }
.ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; }
.ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); }
.ss-segs-preview__body { min-height: 320px; overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); }
.ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; }
.ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; }
.ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; }
.ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ss-segs-preview__focus { display: grid; grid-template-columns: minmax(96px, 42%) 1fr; gap: 3px 9px; align-items: start; padding: 7px; border-top: 1px solid var(--border-color, #444); }
.ss-segs-preview__focus canvas { grid-row: 1 / span 2; width: 100%; height: auto; border-radius: 4px; background: #111; }
.ss-segs-preview__focus span { color: var(--descrip-text, #aaa); }
.ss-segs-preview__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 6px; max-height: 430px; padding: 7px; overflow: auto; }
.ss-segs-preview__card { min-width: 0; padding: 4px; border: 1px solid var(--border-color, #444); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; text-align: left; }
.ss-segs-preview__card[data-highlighted="true"], .ss-segs-preview__card[aria-selected="true"] { border-color: var(--p-primary-color, #6aa9ff); box-shadow: 0 0 0 1px var(--p-primary-color, #6aa9ff); }
.ss-segs-preview__card canvas { display: block; width: 100%; height: 94px; object-fit: contain; margin-bottom: 4px; background: #111; }
.ss-segs-preview__card span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ss-segs-preview__message, .ss-segs-preview__empty { display: grid; min-height: 320px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; }
.ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); }
`;
document.head.append(style);
stylesInstalled = true;
}
// web/src/segPreviewNode.ts
var SIMPLE_PREVIEW_SEGS_NODE_ID = "SimpleSyrup.SimplePreviewSEGS";
function registerSimplePreviewSEGS(app2, api, loadImage, logger = console) {
const controllers = /* @__PURE__ */ new Map();
const extension = {
name: "SimpleSyrup.SimplePreviewSEGS",
nodeCreated(candidate) {
if (!isPreviewNode(candidate)) return;
const inspector = new SegPreviewInspector(
(path) => api.apiURL?.(path) ?? path,
loadImage
);
const controller = new InteractiveInspectorController(
inspector,
parseSegPreviewDocument,
logger
);
const widget = candidate.addDOMWidget(
"simple_syrup_segs_preview",
"simple_syrup_segs_preview",
inspector.element,
{ serialize: false, canvasOnly: false }
);
widget.serialize = false;
widget.options ??= {};
widget.options.serialize = false;
widget.options.canvasOnly = false;
widget.computeSize = (width = 420) => [Math.max(360, width), 470];
const registerId = () => {
if (candidate.id !== void 0) {
controllers.set(String(candidate.id), controller);
}
};
registerId();
const originalExecuted = candidate.onExecuted;
candidate.onExecuted = function(output) {
originalExecuted?.call(this, output);
controller.update(output);
};
const originalGraphConfigured = candidate.onGraphConfigured;
candidate.onGraphConfigured = function(...args) {
const result = originalGraphConfigured?.apply(this, args);
registerId();
if (candidate.id !== void 0) {
controller.update(app2.nodeOutputs?.[String(candidate.id)]);
}
return result;
};
const originalRemoved = candidate.onRemoved;
candidate.onRemoved = function(...args) {
if (candidate.id !== void 0) controllers.delete(String(candidate.id));
controller.dispose();
return originalRemoved?.apply(this, args);
};
const computed = candidate.computeSize?.();
const current = candidate.size ?? computed;
if (current && candidate.setSize) {
candidate.setSize([
Math.max(420, current[0]),
Math.max(520, computed?.[1] ?? current[1])
]);
}
},
onNodeOutputsUpdated(outputs) {
for (const [nodeId, output] of Object.entries(outputs)) {
controllers.get(nodeId)?.update(output);
}
}
};
app2.registerExtension(extension);
}
function isPreviewNode(value) {
if (typeof value !== "object" || value === null) return false;
const node = value;
return node.constructor?.comfyClass === SIMPLE_PREVIEW_SEGS_NODE_ID && typeof node.addDOMWidget === "function";
}
// web/src/main.ts
var comfyApp = app;
var comfyExecutionEvents = window.comfyAPI.api.api;
var comfyApi = window.comfyAPI.api.api;
comfyApp.registerExtension({
name: "SimpleSyrup.Settings",
async setup(appInstance) {
@@ -905,4 +1681,5 @@ comfyApp.registerExtension({
registerExternalLLMRefreshHook(appInstance);
}
});
registerMaskBatchUpload(comfyApp, comfyExecutionEvents);
registerMaskBatchUpload(comfyApp, comfyApi);
registerSimplePreviewSEGS(comfyApp, comfyApi);
+44
View File
@@ -0,0 +1,44 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import type { Dimensions, Rectangle } from "./segPreviewTypes";
export interface Point {
x: number;
y: number;
}
/** Map pointer and source geometry into a displayed image viewport. */
export class ImageViewport {
constructor(
private readonly source: Dimensions,
private readonly display: Dimensions
) {}
/** Convert browser pointer coordinates into source-image coordinates. */
sourcePoint(clientX: number, clientY: number, bounds: DOMRect): Point {
const normalizedX = clamp((clientX - bounds.left) / Math.max(1, bounds.width));
const normalizedY = clamp((clientY - bounds.top) / Math.max(1, bounds.height));
return {
x: normalizedX * this.source.width,
y: normalizedY * this.source.height
};
}
/** Project one source rectangle into the preview canvas. */
displayRectangle(sourceRectangle: Rectangle): Rectangle {
const scaleX = this.display.width / this.source.width;
const scaleY = this.display.height / this.source.height;
return {
x: sourceRectangle.x * scaleX,
y: sourceRectangle.y * scaleY,
width: sourceRectangle.width * scaleX,
height: sourceRectangle.height * scaleY
};
}
}
function clamp(value: number): number {
return Math.max(0, Math.min(1, value));
}
+76
View File
@@ -0,0 +1,76 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import type { Logger } from "./types";
export interface PreparedInspectorState {
commit(): void;
dispose(): void;
}
export interface AsyncInspectorView<TDocument> {
readonly element: HTMLElement;
setLoading(): void;
prepare(document: TDocument): Promise<PreparedInspectorState>;
showError(message: string): void;
dispose(): void;
}
/** Publish only the latest asynchronously prepared execution document. */
export class InteractiveInspectorController<TDocument> {
private version = 0;
private disposed = false;
private committed: PreparedInspectorState | undefined;
constructor(
private readonly view: AsyncInspectorView<TDocument>,
private readonly parse: (output: unknown) => TDocument | undefined,
private readonly logger: Logger = console
) {}
/** Parse and asynchronously display one execution output. */
update(output: unknown): void {
if (this.disposed) return;
const version = ++this.version;
let document: TDocument | undefined;
try {
document = this.parse(output);
} catch (error: unknown) {
this.fail(error);
return;
}
if (!document) return;
this.view.setLoading();
void this.view
.prepare(document)
.then((prepared) => {
if (this.disposed || version !== this.version) {
prepared.dispose();
return;
}
this.committed?.dispose();
this.committed = prepared;
prepared.commit();
})
.catch((error: unknown) => {
if (!this.disposed && version === this.version) this.fail(error);
});
}
/** Release committed state and prevent pending work from publishing. */
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.version += 1;
this.committed?.dispose();
this.committed = undefined;
this.view.dispose();
}
private fail(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Could not display Simple Preview SEGS: ${message}`, error);
this.view.showError(message);
}
}
+6 -4
View File
@@ -8,14 +8,15 @@ import { app } from "../../../scripts/app.js";
import { registerSimpleSyrupSettings } from "./settings";
import { registerExternalLLMRefreshHook } from "./refresh";
import { registerMaskBatchUpload } from "./maskBatchUpload";
import type { ComfyApp, ComfyExecutionEvents } from "./types";
import { registerSimplePreviewSEGS } from "./segPreviewNode";
import type { ComfyApi, ComfyApp } from "./types";
interface ComfyRuntimeWindow extends Window {
comfyAPI: { api: { api: ComfyExecutionEvents } };
comfyAPI: { api: { api: ComfyApi } };
}
const comfyApp = app as unknown as ComfyApp;
const comfyExecutionEvents = (window as unknown as ComfyRuntimeWindow).comfyAPI
const comfyApi = (window as unknown as ComfyRuntimeWindow).comfyAPI
.api.api;
comfyApp.registerExtension({
@@ -26,4 +27,5 @@ comfyApp.registerExtension({
}
});
registerMaskBatchUpload(comfyApp, comfyExecutionEvents);
registerMaskBatchUpload(comfyApp, comfyApi);
registerSimplePreviewSEGS(comfyApp, comfyApi);
+100
View File
@@ -0,0 +1,100 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
SegPreviewDocument,
SegPreviewRegion
} from "./segPreviewTypes";
/** Provide mask sampling and RGBA extraction from one packed atlas image. */
export class MaskAtlas {
constructor(
private readonly document: SegPreviewDocument,
private readonly pixels: Uint8ClampedArray
) {
const expected = document.atlas.width * document.atlas.height * 4;
if (pixels.length !== expected) {
throw new Error("Simple Preview SEGS atlas pixels do not match its dimensions.");
}
}
/** Return every mask containing a source point, most specific first. */
hitsAt(sourceX: number, sourceY: number): SegPreviewRegion[] {
return this.document.regions
.filter((region) => this.contains(region, sourceX, sourceY))
.sort((left, right) => left.area - right.area || left.index - right.index);
}
/** Return one colored RGBA image for a packed region mask. */
coloredMask(region: SegPreviewRegion): ImageData {
const color = parseColor(region.color);
const data = new Uint8ClampedArray(region.atlas.width * region.atlas.height * 4);
for (let y = 0; y < region.atlas.height; y += 1) {
for (let x = 0; x < region.atlas.width; x += 1) {
const atlasOffset = this.offset(region.atlas.x + x, region.atlas.y + y);
const mask = this.pixels[atlasOffset] ?? 0;
const outputOffset = (y * region.atlas.width + x) * 4;
data[outputOffset] = color[0];
data[outputOffset + 1] = color[1];
data[outputOffset + 2] = color[2];
data[outputOffset + 3] = mask;
}
}
return new ImageData(data, region.atlas.width, region.atlas.height);
}
private contains(
region: SegPreviewRegion,
sourceX: number,
sourceY: number
): boolean {
const crop = region.crop;
if (
sourceX < crop.x ||
sourceY < crop.y ||
sourceX >= crop.x + crop.width ||
sourceY >= crop.y + crop.height
) {
return false;
}
const localX = Math.min(
region.atlas.width - 1,
Math.floor(((sourceX - crop.x) / crop.width) * region.atlas.width)
);
const localY = Math.min(
region.atlas.height - 1,
Math.floor(((sourceY - crop.y) / crop.height) * region.atlas.height)
);
return (this.pixels[this.offset(region.atlas.x + localX, region.atlas.y + localY)] ?? 0) >= 128;
}
private offset(x: number, y: number): number {
return (y * this.document.atlas.width + x) * 4;
}
}
/** Read one loaded atlas image into deterministic hit-test pixels. */
export function maskAtlasFromImage(
previewDocument: SegPreviewDocument,
image: CanvasImageSource
): MaskAtlas {
const canvas = globalThis.document.createElement("canvas");
canvas.width = previewDocument.atlas.width;
canvas.height = previewDocument.atlas.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Simple Preview SEGS could not read its mask atlas.");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return new MaskAtlas(
previewDocument,
context.getImageData(0, 0, canvas.width, canvas.height).data
);
}
function parseColor(value: string): [number, number, number] {
return [
Number.parseInt(value.slice(1, 3), 16),
Number.parseInt(value.slice(3, 5), 16),
Number.parseInt(value.slice(5, 7), 16)
];
}
+439
View File
@@ -0,0 +1,439 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type AsyncInspectorView,
type PreparedInspectorState
} from "./interactiveInspector";
import { ImageViewport } from "./imageViewport";
import { maskAtlasFromImage } from "./maskAtlas";
import type { MaskAtlas } from "./maskAtlas";
import type {
SegPreviewDocument,
SegPreviewRegion
} from "./segPreviewTypes";
import { previewAssetUrl } from "./segPreviewTypes";
import { SelectionModel, type SelectionState } from "./selectionModel";
type PreviewMode = "overlay" | "grid";
export type PreviewImageLoader = (url: string) => Promise<HTMLImageElement>;
interface CommittedPreview {
document: SegPreviewDocument;
image: HTMLImageElement;
atlas: MaskAtlas;
viewport: ImageViewport;
masks: Map<string, HTMLCanvasElement>;
selection: SelectionModel<string>;
}
/** Render the interactive SEGS inspector inside Comfy's native DOM widget. */
export class SegPreviewInspector
implements AsyncInspectorView<SegPreviewDocument>
{
readonly element = document.createElement("section");
private readonly body = document.createElement("div");
private readonly status = document.createElement("div");
private readonly overlayButton = modeButton("Overlay");
private readonly gridButton = modeButton("Grid");
private mode: PreviewMode = "overlay";
private committed: CommittedPreview | undefined;
private unsubscribeSelection: (() => void) | undefined;
private highlightCanvas: HTMLCanvasElement | undefined;
private focusContainer: HTMLElement | undefined;
private gridButtons = new Map<string, HTMLButtonElement>();
constructor(
private readonly apiURL: (path: string) => string = (path) => path,
private readonly loadImage: PreviewImageLoader = loadPreviewImage
) {
installStyles();
this.element.className = "ss-segs-preview";
this.body.className = "ss-segs-preview__body";
this.status.className = "ss-segs-preview__status";
this.status.setAttribute("aria-live", "polite");
const toolbar = document.createElement("nav");
toolbar.className = "ss-segs-preview__toolbar";
toolbar.setAttribute("aria-label", "SEGS preview mode");
toolbar.append(this.overlayButton, this.gridButton);
this.overlayButton.addEventListener("click", () => {
this.setMode("overlay");
});
this.gridButton.addEventListener("click", () => {
this.setMode("grid");
});
this.element.append(toolbar, this.body, this.status);
this.updateModeButtons();
this.showMessage("Run the workflow to inspect SEGS.");
}
/** Show a native loading state while execution assets are decoded. */
setLoading(): void {
this.showMessage("Loading SEGS preview…");
}
/** Load source and atlas assets without publishing partial state. */
async prepare(document: SegPreviewDocument): Promise<PreparedInspectorState> {
const [image, atlasImage] = await Promise.all([
this.loadImage(previewAssetUrl(document.preview.image, this.apiURL)),
this.loadImage(previewAssetUrl(document.atlas.image, this.apiURL))
]);
const atlas = maskAtlasFromImage(document, atlasImage);
let disposed = false;
return {
commit: () => {
if (!disposed) this.commit(document, image, atlas);
},
dispose: () => {
disposed = true;
}
};
}
/** Replace the inspector with an actionable failure message. */
showError(message: string): void {
this.showMessage(message, true);
}
/** Release node-owned DOM and interaction subscriptions. */
dispose(): void {
this.unsubscribeSelection?.();
this.unsubscribeSelection = undefined;
this.committed = undefined;
this.body.replaceChildren();
this.status.textContent = "";
}
private commit(
document: SegPreviewDocument,
image: HTMLImageElement,
atlas: MaskAtlas
): void {
this.unsubscribeSelection?.();
const selection = new SelectionModel<string>();
const masks = new Map(
document.regions.map((region) => [
region.id,
maskCanvas(atlas, region)
])
);
this.committed = {
document,
image,
atlas,
viewport: new ImageViewport(document.source, document.preview),
masks,
selection
};
this.unsubscribeSelection = selection.subscribe((state) => {
this.renderSelection(state);
});
this.renderMode();
}
private setMode(mode: PreviewMode): void {
if (this.mode === mode) return;
this.mode = mode;
this.updateModeButtons();
this.renderMode();
}
private updateModeButtons(): void {
this.overlayButton.setAttribute(
"aria-pressed",
String(this.mode === "overlay")
);
this.gridButton.setAttribute("aria-pressed", String(this.mode === "grid"));
}
private renderMode(): void {
this.highlightCanvas = undefined;
this.focusContainer = undefined;
this.gridButtons.clear();
if (!this.committed) return;
this.body.replaceChildren(
this.mode === "overlay" ? this.overlayView() : this.gridView()
);
this.renderSelection(this.committed.selection.state());
}
private overlayView(): HTMLElement {
const committed = required(this.committed);
const container = document.createElement("div");
container.className = "ss-segs-preview__overlay-view";
const stack = document.createElement("div");
stack.className = "ss-segs-preview__canvas-stack";
const base = sizedCanvas(committed.document.preview);
const highlight = sizedCanvas(committed.document.preview);
highlight.className = "ss-segs-preview__highlight";
this.highlightCanvas = highlight;
const context = context2d(base);
context.drawImage(
committed.image,
0,
0,
committed.document.preview.width,
committed.document.preview.height
);
context.globalAlpha = 0.38;
for (const region of committed.document.regions) {
drawRegionMask(context, committed, region);
}
context.globalAlpha = 1;
highlight.addEventListener("pointermove", (event) => {
const point = committed.viewport.sourcePoint(
event.clientX,
event.clientY,
highlight.getBoundingClientRect()
);
const hits = committed.atlas.hitsAt(point.x, point.y);
committed.selection.hover(hits.map((region) => region.id));
});
highlight.addEventListener("pointerleave", () => {
committed.selection.clearHover();
});
highlight.addEventListener("click", () => {
committed.selection.selectNextCandidate();
});
stack.append(base, highlight);
this.focusContainer = document.createElement("div");
this.focusContainer.className = "ss-segs-preview__focus";
container.append(stack, this.focusContainer);
return container;
}
private gridView(): HTMLElement {
const committed = required(this.committed);
const grid = document.createElement("div");
grid.className = "ss-segs-preview__grid";
grid.setAttribute("role", "listbox");
if (committed.document.regions.length === 0) {
const empty = document.createElement("div");
empty.className = "ss-segs-preview__empty";
empty.textContent = "No SEGS to display.";
grid.append(empty);
return grid;
}
for (const region of committed.document.regions) {
const card = document.createElement("button");
card.type = "button";
card.className = "ss-segs-preview__card";
card.setAttribute("role", "option");
card.dataset.regionId = region.id;
const canvas = regionPreviewCanvas(committed, region, 160, 132);
const label = document.createElement("span");
label.textContent = regionTitle(region);
card.append(canvas, label);
card.addEventListener("pointerenter", () => {
committed.selection.hover([region.id]);
});
card.addEventListener("pointerleave", () => {
committed.selection.clearHover();
});
card.addEventListener("click", () => {
committed.selection.select(region.id);
});
this.gridButtons.set(region.id, card);
grid.append(card);
}
return grid;
}
private renderSelection(state: SelectionState<string>): void {
const committed = this.committed;
if (!committed) return;
const active = state.active
? committed.document.regions.find((region) => region.id === state.active)
: undefined;
if (this.highlightCanvas) {
const context = context2d(this.highlightCanvas);
context.clearRect(
0,
0,
this.highlightCanvas.width,
this.highlightCanvas.height
);
if (active) {
context.save();
context.globalAlpha = 0.9;
context.shadowColor = "rgba(255, 255, 255, 0.95)";
context.shadowBlur = 8;
drawRegionMask(context, committed, active);
context.restore();
}
}
for (const [id, button] of this.gridButtons) {
const selected = id === state.selected;
const highlighted = id === active?.id;
button.setAttribute("aria-selected", String(selected));
button.dataset.highlighted = String(highlighted);
}
this.renderFocus(active);
if (!active) {
this.status.textContent = `${String(committed.document.regions.length)} regions`;
} else if (state.candidates.length > 1) {
this.status.textContent = `${regionTitle(active)} · ${String(state.candidates.length)} overlapping regions · click to cycle`;
} else {
this.status.textContent = regionTitle(active);
}
}
private renderFocus(region: SegPreviewRegion | undefined): void {
if (!this.focusContainer || !this.committed) return;
if (!region) {
this.focusContainer.replaceChildren();
return;
}
const title = document.createElement("strong");
title.textContent = regionTitle(region);
const details = document.createElement("span");
details.textContent = `${String(Math.round(region.confidence * 100))}% confidence · ${region.area.toLocaleString()} px`;
this.focusContainer.replaceChildren(
regionPreviewCanvas(this.committed, region, 320, 220),
title,
details
);
}
private showMessage(message: string, error = false): void {
const content = document.createElement("div");
content.className = "ss-segs-preview__message";
content.dataset.error = String(error);
content.textContent = message;
this.body.replaceChildren(content);
this.status.textContent = "";
}
}
function drawRegionMask(
context: CanvasRenderingContext2D,
committed: CommittedPreview,
region: SegPreviewRegion
): void {
const placement = committed.viewport.displayRectangle(region.crop);
const mask = committed.masks.get(region.id);
if (!mask) return;
context.drawImage(
mask,
placement.x,
placement.y,
placement.width,
placement.height
);
}
function regionPreviewCanvas(
committed: CommittedPreview,
region: SegPreviewRegion,
maximumWidth: number,
maximumHeight: number
): HTMLCanvasElement {
const aspect = region.crop.width / region.crop.height;
const width = Math.max(80, Math.min(maximumWidth, Math.round(maximumHeight * aspect)));
const height = Math.max(64, Math.min(maximumHeight, Math.round(width / aspect)));
const canvas = sizedCanvas({ width, height });
const context = context2d(canvas);
const source = committed.viewport.displayRectangle(region.crop);
context.drawImage(
committed.image,
source.x,
source.y,
source.width,
source.height,
0,
0,
width,
height
);
context.globalAlpha = 0.45;
const mask = committed.masks.get(region.id);
if (mask) context.drawImage(mask, 0, 0, width, height);
context.globalAlpha = 1;
return canvas;
}
function maskCanvas(atlas: MaskAtlas, region: SegPreviewRegion): HTMLCanvasElement {
const canvas = sizedCanvas(region.atlas);
context2d(canvas).putImageData(atlas.coloredMask(region), 0, 0);
return canvas;
}
function sizedCanvas(size: { width: number; height: number }): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
return canvas;
}
function context2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const context = canvas.getContext("2d");
if (!context) throw new Error("Simple Preview SEGS requires canvas rendering.");
return context;
}
function regionTitle(region: SegPreviewRegion): string {
const label = region.label.trim();
return `${String(region.index + 1)}. ${label || "region"}`;
}
function modeButton(label: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
return button;
}
function required<T>(value: T | undefined): T {
if (!value) throw new Error("Simple Preview SEGS has no committed document.");
return value;
}
function loadPreviewImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => {
resolve(image);
}, { once: true });
image.addEventListener(
"error",
() => {
reject(new Error("Simple Preview SEGS could not load a preview asset."));
},
{ once: true }
);
image.src = url;
});
}
let stylesInstalled = false;
function installStyles(): void {
if (stylesInstalled) return;
const style = document.createElement("style");
style.dataset.simpleSyrupSegsPreview = "true";
style.textContent = `
.ss-segs-preview { box-sizing: border-box; width: 100%; min-height: 360px; color: var(--fg-color, #ddd); font: 12px sans-serif; }
.ss-segs-preview * { box-sizing: border-box; }
.ss-segs-preview__toolbar { display: flex; gap: 4px; margin: 0 0 6px; }
.ss-segs-preview__toolbar button { flex: 1; min-height: 26px; border: 1px solid var(--border-color, #555); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; }
.ss-segs-preview__toolbar button[aria-pressed="true"] { border-color: var(--p-primary-color, #6aa9ff); background: color-mix(in srgb, var(--p-primary-color, #6aa9ff) 28%, var(--comfy-input-bg, #222)); }
.ss-segs-preview__body { min-height: 320px; overflow: hidden; border: 1px solid var(--border-color, #444); border-radius: 6px; background: var(--comfy-menu-bg, #181818); }
.ss-segs-preview__canvas-stack { position: relative; line-height: 0; background: #111; }
.ss-segs-preview__canvas-stack canvas { display: block; width: 100%; height: auto; }
.ss-segs-preview__highlight { position: absolute; inset: 0; cursor: crosshair; }
.ss-segs-preview__status { min-height: 22px; padding: 5px 2px 0; color: var(--descrip-text, #aaa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ss-segs-preview__focus { display: grid; grid-template-columns: minmax(96px, 42%) 1fr; gap: 3px 9px; align-items: start; padding: 7px; border-top: 1px solid var(--border-color, #444); }
.ss-segs-preview__focus canvas { grid-row: 1 / span 2; width: 100%; height: auto; border-radius: 4px; background: #111; }
.ss-segs-preview__focus span { color: var(--descrip-text, #aaa); }
.ss-segs-preview__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 6px; max-height: 430px; padding: 7px; overflow: auto; }
.ss-segs-preview__card { min-width: 0; padding: 4px; border: 1px solid var(--border-color, #444); border-radius: 5px; color: inherit; background: var(--comfy-input-bg, #222); cursor: pointer; text-align: left; }
.ss-segs-preview__card[data-highlighted="true"], .ss-segs-preview__card[aria-selected="true"] { border-color: var(--p-primary-color, #6aa9ff); box-shadow: 0 0 0 1px var(--p-primary-color, #6aa9ff); }
.ss-segs-preview__card canvas { display: block; width: 100%; height: 94px; object-fit: contain; margin-bottom: 4px; background: #111; }
.ss-segs-preview__card span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ss-segs-preview__message, .ss-segs-preview__empty { display: grid; min-height: 320px; place-items: center; padding: 18px; color: var(--descrip-text, #aaa); text-align: center; }
.ss-segs-preview__message[data-error="true"] { color: var(--error-text, #ff8a80); }
`;
document.head.append(style);
stylesInstalled = true;
}
+139
View File
@@ -0,0 +1,139 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { InteractiveInspectorController } from "./interactiveInspector";
import { SegPreviewInspector, type PreviewImageLoader } from "./segPreviewInspector";
import {
parseSegPreviewDocument,
type SegPreviewDocument
} from "./segPreviewTypes";
import type {
ComfyApi,
ComfyApp,
ComfyExtension,
ComfyNodeExecutionOutput,
Logger
} from "./types";
const SIMPLE_PREVIEW_SEGS_NODE_ID = "SimpleSyrup.SimplePreviewSEGS";
interface DomWidget {
serialize?: boolean;
computeSize?: (width?: number) => [number, number];
options?: {
serialize?: boolean;
canvasOnly?: boolean;
};
}
interface PreviewNode {
constructor: { comfyClass?: string };
id?: string | number;
size?: [number, number];
onExecuted?: (output: unknown) => void;
onGraphConfigured?: (...args: unknown[]) => unknown;
onRemoved?: (...args: unknown[]) => unknown;
computeSize?: () => [number, number];
setSize?: (size: [number, number]) => void;
addDOMWidget(
name: string,
type: string,
element: HTMLElement,
options?: DomWidget["options"]
): DomWidget;
}
/** Register the renderer-neutral interactive SEGS inspector node widget. */
export function registerSimplePreviewSEGS(
app: ComfyApp,
api: ComfyApi,
loadImage?: PreviewImageLoader,
logger: Logger = console
): void {
const controllers = new Map<
string,
InteractiveInspectorController<SegPreviewDocument>
>();
const extension: ComfyExtension = {
name: "SimpleSyrup.SimplePreviewSEGS",
nodeCreated(candidate: unknown) {
if (!isPreviewNode(candidate)) return;
const inspector = new SegPreviewInspector(
(path) => api.apiURL?.(path) ?? path,
loadImage
);
const controller = new InteractiveInspectorController(
inspector,
parseSegPreviewDocument,
logger
);
const widget = candidate.addDOMWidget(
"simple_syrup_segs_preview",
"simple_syrup_segs_preview",
inspector.element,
{ serialize: false, canvasOnly: false }
);
widget.serialize = false;
widget.options ??= {};
widget.options.serialize = false;
widget.options.canvasOnly = false;
widget.computeSize = (width = 420) => [Math.max(360, width), 470];
const registerId = (): void => {
if (candidate.id !== undefined) {
controllers.set(String(candidate.id), controller);
}
};
registerId();
const originalExecuted = candidate.onExecuted;
candidate.onExecuted = function (output: unknown): void {
originalExecuted?.call(this, output);
controller.update(output);
};
const originalGraphConfigured = candidate.onGraphConfigured;
candidate.onGraphConfigured = function (...args: unknown[]): unknown {
const result = originalGraphConfigured?.apply(this, args);
registerId();
if (candidate.id !== undefined) {
controller.update(app.nodeOutputs?.[String(candidate.id)]);
}
return result;
};
const originalRemoved = candidate.onRemoved;
candidate.onRemoved = function (...args: unknown[]): unknown {
if (candidate.id !== undefined) controllers.delete(String(candidate.id));
controller.dispose();
return originalRemoved?.apply(this, args);
};
const computed = candidate.computeSize?.();
const current = candidate.size ?? computed;
if (current && candidate.setSize) {
candidate.setSize([
Math.max(420, current[0]),
Math.max(520, computed?.[1] ?? current[1])
]);
}
},
onNodeOutputsUpdated(outputs: Record<string, ComfyNodeExecutionOutput>) {
for (const [nodeId, output] of Object.entries(outputs)) {
controllers.get(nodeId)?.update(output);
}
}
};
app.registerExtension(extension);
}
function isPreviewNode(value: unknown): value is PreviewNode {
if (typeof value !== "object" || value === null) return false;
const node = value as Partial<PreviewNode>;
return (
node.constructor?.comfyClass === SIMPLE_PREVIEW_SEGS_NODE_ID &&
typeof node.addDOMWidget === "function"
);
}
+181
View File
@@ -0,0 +1,181 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import type { ComfyImageResult, ComfyNodeExecutionOutput } from "./types";
export const SEG_PREVIEW_OUTPUT_KEY = "simple_syrup_segs_preview";
export interface Dimensions {
width: number;
height: number;
}
export interface Rectangle extends Dimensions {
x: number;
y: number;
}
export interface SegPreviewRegion {
id: string;
index: number;
label: string;
confidence: number;
area: number;
color: string;
crop: Rectangle;
atlas: Rectangle;
}
export interface SegPreviewDocument {
version: 1;
source: Dimensions;
preview: Dimensions & { image: ComfyImageResult };
atlas: Dimensions & { image: ComfyImageResult };
regions: SegPreviewRegion[];
}
export interface SegPreviewExecutionOutput extends ComfyNodeExecutionOutput {
[SEG_PREVIEW_OUTPUT_KEY]?: unknown;
}
/** Return the latest valid preview document from one execution payload. */
export function parseSegPreviewDocument(
output: unknown
): SegPreviewDocument | undefined {
if (!isRecord(output)) return undefined;
const candidates = output[SEG_PREVIEW_OUTPUT_KEY];
if (!Array.isArray(candidates) || candidates.length === 0) return undefined;
const candidate: unknown = candidates[candidates.length - 1];
if (!isRecord(candidate) || candidate.version !== 1) {
throw new Error("Simple Preview SEGS received an unsupported preview payload.");
}
const source = parseDimensions(candidate.source, "source");
const preview = parseAssetDimensions(candidate.preview, "preview");
const atlas = parseAssetDimensions(candidate.atlas, "atlas");
if (!Array.isArray(candidate.regions)) {
throw new Error("Simple Preview SEGS regions must be a list.");
}
const regions = candidate.regions.map(parseRegion);
return { version: 1, source, preview, atlas, regions };
}
/** Build a Comfy view URL without assuming it is hosted at the origin root. */
export function previewAssetUrl(
reference: ComfyImageResult,
apiURL: (path: string) => string = (path) => path
): string {
const query = new URLSearchParams({
filename: reference.filename,
subfolder: reference.subfolder,
type: reference.type
});
return apiURL(`/view?${query.toString()}`);
}
function parseRegion(value: unknown, index: number): SegPreviewRegion {
if (!isRecord(value)) {
throw new Error(
`Simple Preview SEGS region ${String(index + 1)} must be an object.`
);
}
const id = stringValue(value.id, "region id");
const label = stringValue(value.label, "region label");
const color = stringValue(value.color, "region color");
if (!/^#[0-9a-f]{6}$/i.test(color)) {
throw new Error(`Simple Preview SEGS region '${id}' has an invalid color.`);
}
return {
id,
index: integerValue(value.index, "region index", 0),
label,
confidence: numberValue(value.confidence, "region confidence", 0),
area: integerValue(value.area, "region area", 0),
color,
crop: parseRectangle(value.crop, `region '${id}' crop`),
atlas: parseRectangle(value.atlas, `region '${id}' atlas`)
};
}
function parseAssetDimensions(
value: unknown,
name: string
): Dimensions & { image: ComfyImageResult } {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} must be an object.`);
}
return {
...parseDimensions(value, name),
image: parseImageReference(value.image, name)
};
}
function parseDimensions(value: unknown, name: string): Dimensions {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} dimensions must be an object.`);
}
return {
width: integerValue(value.width, `${name} width`, 1),
height: integerValue(value.height, `${name} height`, 1)
};
}
function parseRectangle(value: unknown, name: string): Rectangle {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} must be an object.`);
}
return {
x: integerValue(value.x, `${name} x`, 0),
y: integerValue(value.y, `${name} y`, 0),
width: integerValue(value.width, `${name} width`, 1),
height: integerValue(value.height, `${name} height`, 1)
};
}
function parseImageReference(value: unknown, name: string): ComfyImageResult {
if (!isRecord(value)) {
throw new Error(`Simple Preview SEGS ${name} image reference is invalid.`);
}
const type = stringValue(value.type, `${name} image type`);
if (type !== "input" && type !== "output" && type !== "temp") {
throw new Error(`Simple Preview SEGS ${name} image type is invalid.`);
}
return {
filename: stringValue(value.filename, `${name} image filename`),
subfolder: stringValue(value.subfolder, `${name} image subfolder`),
type
};
}
function integerValue(
value: unknown,
name: string,
minimum: number
): number {
if (!Number.isInteger(value) || (value as number) < minimum) {
throw new Error(
`Simple Preview SEGS ${name} must be at least ${String(minimum)}.`
);
}
return value as number;
}
function numberValue(value: unknown, name: string, minimum: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
throw new Error(
`Simple Preview SEGS ${name} must be at least ${String(minimum)}.`
);
}
return value;
}
function stringValue(value: unknown, name: string): string {
if (typeof value !== "string") {
throw new Error(`Simple Preview SEGS ${name} must be text.`);
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+72
View File
@@ -0,0 +1,72 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface SelectionState<TId> {
hovered: TId | null;
selected: TId | null;
candidates: readonly TId[];
active: TId | null;
}
export type SelectionSubscriber<TId> = (state: SelectionState<TId>) => void;
/** Coordinate hover and pinned selection across inspector representations. */
export class SelectionModel<TId> {
private hovered: TId | null = null;
private selected: TId | null = null;
private candidates: readonly TId[] = [];
private readonly subscribers = new Set<SelectionSubscriber<TId>>();
/** Subscribe to selection changes and receive the current state immediately. */
subscribe(subscriber: SelectionSubscriber<TId>): () => void {
this.subscribers.add(subscriber);
subscriber(this.state());
return () => this.subscribers.delete(subscriber);
}
/** Replace the hover target and all regions currently beneath the pointer. */
hover(candidates: readonly TId[]): void {
this.candidates = [...candidates];
this.hovered = candidates[0] ?? null;
this.publish();
}
/** Clear transient pointer state without changing the pinned region. */
clearHover(): void {
this.candidates = [];
this.hovered = null;
this.publish();
}
/** Pin one region, or clear the pinned selection with null. */
select(id: TId | null): void {
this.selected = id;
this.publish();
}
/** Cycle through overlapping pointer candidates and pin the result. */
selectNextCandidate(): TId | null {
if (this.candidates.length === 0) return this.selected;
const currentIndex =
this.selected === null ? -1 : this.candidates.indexOf(this.selected);
const next = this.candidates[(currentIndex + 1) % this.candidates.length] ?? null;
this.select(next);
return next;
}
/** Return immutable selection state for rendering or tests. */
state(): SelectionState<TId> {
return {
hovered: this.hovered,
selected: this.selected,
candidates: [...this.candidates],
active: this.hovered ?? this.selected
};
}
private publish(): void {
const state = this.state();
for (const subscriber of this.subscribers) subscriber(state);
}
}
+7
View File
@@ -35,6 +35,10 @@ export interface ComfyApp {
registerExtension(extension: ComfyExtension): void;
}
export interface ComfyApi extends ComfyExecutionEvents {
apiURL?(path: string): string;
}
/** Native Comfy event target used to publish execution-shaped node output. */
export type ComfyExecutionEvents = Pick<EventTarget, "dispatchEvent">;
@@ -53,4 +57,7 @@ export interface ComfyExtension {
name: string;
setup?(app: ComfyApp): void | Promise<void>;
nodeCreated?(node: unknown): void | Promise<void>;
onNodeOutputsUpdated?(
outputs: Record<string, ComfyNodeExecutionOutput>
): void | Promise<void>;
}
+22
View File
@@ -0,0 +1,22 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from "vitest";
import { ImageViewport } from "../src/imageViewport";
describe("ImageViewport", () => {
it("maps displayed pointer and crop geometry to the source image", () => {
const viewport = new ImageViewport(
{ width: 2000, height: 1000 },
{ width: 1000, height: 500 }
);
const bounds = new DOMRect(100, 50, 500, 250);
expect(viewport.sourcePoint(350, 175, bounds)).toEqual({ x: 1000, y: 500 });
expect(
viewport.displayRectangle({ x: 200, y: 100, width: 600, height: 300 })
).toEqual({ x: 100, y: 50, width: 300, height: 150 });
});
});
+117
View File
@@ -0,0 +1,117 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it, vi } from "vitest";
import {
InteractiveInspectorController,
type AsyncInspectorView,
type PreparedInspectorState
} from "../src/interactiveInspector";
describe("InteractiveInspectorController", () => {
it("discards stale prepared documents and commits only the latest", async () => {
const first = deferred<PreparedInspectorState>();
const second = deferred<PreparedInspectorState>();
const view = fakeView([first.promise, second.promise]);
const controller = new InteractiveInspectorController(view, (output) =>
typeof output === "string" ? output : undefined
);
const stale = preparedState();
const latest = preparedState();
controller.update("first");
controller.update("second");
second.resolve(latest);
await vi.waitFor(() => {
expect(latest.commitSpy).toHaveBeenCalledOnce();
});
first.resolve(stale);
await vi.waitFor(() => {
expect(stale.disposeSpy).toHaveBeenCalledOnce();
});
expect(stale.commitSpy).not.toHaveBeenCalled();
});
it("disposes committed state and blocks pending publication", async () => {
const pending = deferred<PreparedInspectorState>();
const view = fakeView([pending.promise]);
const controller = new InteractiveInspectorController(view, () => "document");
const prepared = preparedState();
controller.update({});
controller.dispose();
pending.resolve(prepared);
await vi.waitFor(() => {
expect(prepared.disposeSpy).toHaveBeenCalledOnce();
});
expect(prepared.commitSpy).not.toHaveBeenCalled();
expect(view.disposeSpy).toHaveBeenCalledOnce();
});
});
interface TestInspectorView extends AsyncInspectorView<string> {
disposeSpy: ReturnType<typeof vi.fn<() => void>>;
}
function fakeView(
preparations: Array<Promise<PreparedInspectorState>>
): TestInspectorView {
let index = 0;
const disposeSpy = vi.fn<() => void>();
return {
element: document.createElement("div"),
setLoading: vi.fn(),
prepare: vi.fn(() => {
const preparation = preparations[index];
index += 1;
if (!preparation) throw new Error("Missing test preparation.");
return preparation;
}),
showError: vi.fn(),
dispose: () => {
disposeSpy();
},
disposeSpy
};
}
interface TestPreparedState extends PreparedInspectorState {
commitSpy: ReturnType<typeof vi.fn<() => void>>;
disposeSpy: ReturnType<typeof vi.fn<() => void>>;
}
function preparedState(): TestPreparedState {
const commitSpy = vi.fn<() => void>();
const disposeSpy = vi.fn<() => void>();
return {
commit: () => {
commitSpy();
},
dispose: () => {
disposeSpy();
},
commitSpy,
disposeSpy
};
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolver: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => {
resolver = resolve;
});
return {
promise,
resolve(value: T): void {
if (!resolver) throw new Error("Deferred resolver was not initialized.");
resolver(value);
}
};
}
+74
View File
@@ -0,0 +1,74 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from "vitest";
import { MaskAtlas } from "../src/maskAtlas";
import type { SegPreviewDocument } from "../src/segPreviewTypes";
describe("MaskAtlas", () => {
it("returns nested hits from smallest to largest and respects mask holes", () => {
const document = previewDocument();
const pixels = new Uint8ClampedArray(4 * 2 * 4);
setMaskPixel(pixels, 4, 0, 0, 255);
setMaskPixel(pixels, 4, 1, 0, 255);
setMaskPixel(pixels, 4, 2, 0, 255);
setMaskPixel(pixels, 4, 3, 0, 255);
setMaskPixel(pixels, 4, 0, 1, 0);
setMaskPixel(pixels, 4, 1, 1, 255);
setMaskPixel(pixels, 4, 2, 1, 255);
setMaskPixel(pixels, 4, 3, 1, 255);
const atlas = new MaskAtlas(document, pixels);
expect(atlas.hitsAt(25, 25).map((region) => region.id)).toEqual([
"small",
"large"
]);
expect(atlas.hitsAt(75, 25).map((region) => region.id)).toEqual(["large"]);
expect(atlas.hitsAt(25, 75)).toEqual([]);
expect(atlas.hitsAt(150, 50)).toEqual([]);
});
});
function previewDocument(): SegPreviewDocument {
const image = { filename: "asset.png", subfolder: "", type: "temp" as const };
return {
version: 1,
source: { width: 200, height: 100 },
preview: { width: 200, height: 100, image },
atlas: { width: 4, height: 2, image },
regions: [
{
id: "large",
index: 0,
label: "subject",
confidence: 1,
area: 10000,
color: "#ff0000",
crop: { x: 0, y: 0, width: 100, height: 100 },
atlas: { x: 0, y: 0, width: 2, height: 2 }
},
{
id: "small",
index: 1,
label: "detail",
confidence: 1,
area: 2500,
color: "#00ff00",
crop: { x: 0, y: 0, width: 50, height: 50 },
atlas: { x: 2, y: 0, width: 2, height: 2 }
}
]
};
}
function setMaskPixel(
pixels: Uint8ClampedArray,
width: number,
x: number,
y: number,
value: number
): void {
pixels[(y * width + x) * 4] = value;
}
+99
View File
@@ -0,0 +1,99 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { afterEach, describe, expect, it, vi } from "vitest";
import { SegPreviewInspector } from "../src/segPreviewInspector";
import type { SegPreviewDocument } from "../src/segPreviewTypes";
import { installCanvasMock } from "./testUtils";
describe("SegPreviewInspector", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("shares pinned selection between overlay and grid views", async () => {
installCanvasMock();
const loadImage = vi.fn(() => Promise.resolve(document.createElement("img")));
const inspector = new SegPreviewInspector((path) => path, loadImage);
const prepared = await inspector.prepare(previewDocument());
prepared.commit();
const highlight = requiredElement(
inspector.element,
".ss-segs-preview__highlight"
);
if (!(highlight instanceof HTMLCanvasElement)) {
throw new Error("Missing highlight canvas.");
}
highlight.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100);
highlight.dispatchEvent(
new MouseEvent("pointermove", { clientX: 50, clientY: 50, bubbles: true })
);
expect(inspector.element.textContent).toContain("subject");
highlight.click();
buttonNamed(inspector.element, "Grid").click();
const card = requiredElement(
inspector.element,
".ss-segs-preview__card"
);
if (!(card instanceof HTMLButtonElement)) {
throw new Error("Missing region card.");
}
expect(card.getAttribute("aria-selected")).toBe("true");
expect(card.dataset.highlighted).toBe("true");
});
it("switches to an actionable error without throwing from the node lifecycle", () => {
installCanvasMock();
const inspector = new SegPreviewInspector();
inspector.showError("Atlas could not be decoded.");
expect(inspector.element.textContent).toContain("Atlas could not be decoded.");
expect(
inspector.element.querySelector(".ss-segs-preview__message")?.getAttribute(
"data-error"
)
).toBe("true");
});
});
function previewDocument(): SegPreviewDocument {
const image = { filename: "asset.png", subfolder: "", type: "temp" as const };
return {
version: 1,
source: { width: 100, height: 100 },
preview: { width: 100, height: 100, image },
atlas: { width: 2, height: 2, image },
regions: [
{
id: "seg-0001",
index: 0,
label: "subject",
confidence: 0.95,
area: 10000,
color: "#f24236",
crop: { x: 0, y: 0, width: 100, height: 100 },
atlas: { x: 0, y: 0, width: 2, height: 2 }
}
]
};
}
function buttonNamed(root: ParentNode, name: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find(
(candidate) => candidate.textContent === name
);
if (!button) throw new Error(`Missing '${name}' button.`);
return button;
}
function requiredElement(root: ParentNode, selector: string): Element {
const element = root.querySelector(selector);
if (!element) throw new Error(`Missing '${selector}' element.`);
return element;
}
+141
View File
@@ -0,0 +1,141 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerSimplePreviewSEGS } from "../src/segPreviewNode";
import type {
ComfyApi,
ComfyNodeExecutionOutput,
ComfyExtension
} from "../src/types";
import { createFakeComfyApp, installCanvasMock } from "./testUtils";
describe("Simple Preview SEGS node integration", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each(["Nodes 1.0", "Nodes 2.0"])(
"uses one native DOM widget under %s",
async (renderer) => {
installCanvasMock();
const app = createFakeComfyApp();
const api = new EventTarget() as ComfyApi;
api.apiURL = (path) => `/base${path}`;
const loadImage = vi.fn(() => Promise.resolve(document.createElement("img")));
registerSimplePreviewSEGS(app, api, loadImage);
const extension = requiredExtension(app.extensions);
const node = fakeNode(renderer === "Nodes 2.0" ? 22 : 11);
await extension.nodeCreated?.(node);
expect(node.addDOMWidget).toHaveBeenCalledOnce();
const widgetOptions = node.addDOMWidget.mock.calls[0]?.[3];
expect(widgetOptions).toEqual({ serialize: false, canvasOnly: false });
expect(node.setSize).toHaveBeenCalledWith([420, 520]);
if (renderer === "Nodes 1.0") {
node.onExecuted?.(executionOutput());
} else {
await extension.onNodeOutputsUpdated?.({
[String(node.id)]: executionOutput()
});
}
const root = node.previewRoot;
await vi.waitFor(() => {
expect(root.querySelector(".ss-segs-preview__canvas-stack")).not.toBeNull();
});
node.onRemoved?.();
expect(root.querySelector(".ss-segs-preview__canvas-stack")).toBeNull();
}
);
});
interface FakeDomWidget {
serialize?: boolean;
computeSize?: (width?: number) => [number, number];
options: { serialize?: boolean; canvasOnly?: boolean };
}
interface FakePreviewNode {
constructor: { comfyClass: string };
id: number;
size: [number, number];
previewRoot: HTMLElement;
computeSize: ReturnType<typeof vi.fn<() => [number, number]>>;
setSize: ReturnType<typeof vi.fn<(size: [number, number]) => void>>;
addDOMWidget: ReturnType<
typeof vi.fn<
(
name: string,
type: string,
element: HTMLElement,
options?: { serialize?: boolean; canvasOnly?: boolean }
) => FakeDomWidget
>
>;
onExecuted: ((output: unknown) => void) | undefined;
onGraphConfigured: ((...args: unknown[]) => unknown) | undefined;
onRemoved: ((...args: unknown[]) => unknown) | undefined;
}
function fakeNode(id: number): FakePreviewNode {
const previewRoot: HTMLElement = document.createElement("div");
const node = {
constructor: { comfyClass: "SimpleSyrup.SimplePreviewSEGS" },
id,
size: [300, 200] as [number, number],
previewRoot,
computeSize: vi.fn((): [number, number] => [300, 240]),
setSize: vi.fn<(size: [number, number]) => void>(),
addDOMWidget: vi.fn(
(
_name: string,
_type: string,
element: HTMLElement,
options?: { serialize?: boolean; canvasOnly?: boolean }
): FakeDomWidget => {
void options;
previewRoot.replaceWith(element);
node.previewRoot = element;
return { options: {} };
}
),
onExecuted: undefined as ((output: unknown) => void) | undefined,
onGraphConfigured: undefined as ((...args: unknown[]) => unknown) | undefined,
onRemoved: undefined as ((...args: unknown[]) => unknown) | undefined
};
return node;
}
function executionOutput(): ComfyNodeExecutionOutput & {
simple_syrup_segs_preview: unknown[];
} {
const image = { filename: "asset.png", subfolder: "", type: "temp" as const };
return {
images: [],
simple_syrup_segs_preview: [
{
version: 1,
source: { width: 4, height: 4 },
preview: { width: 4, height: 4, image },
atlas: { width: 1, height: 1, image },
regions: []
}
]
};
}
function requiredExtension(
extensions: ReturnType<typeof createFakeComfyApp>["extensions"]
): ComfyExtension {
const extension = extensions.find(
(candidate) => candidate.name === "SimpleSyrup.SimplePreviewSEGS"
);
if (!extension) throw new Error("Simple Preview SEGS extension was not registered.");
return extension;
}
+59
View File
@@ -0,0 +1,59 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from "vitest";
import {
parseSegPreviewDocument,
previewAssetUrl
} from "../src/segPreviewTypes";
describe("SEG preview transport", () => {
it("validates execution payloads and uses the latest mapped document", () => {
const document = validDocument();
expect(
parseSegPreviewDocument({ simple_syrup_segs_preview: [document] })
).toEqual(document);
expect(parseSegPreviewDocument({ images: [] })).toBeUndefined();
expect(() =>
parseSegPreviewDocument({
simple_syrup_segs_preview: [{ ...document, version: 2 }]
})
).toThrow("unsupported preview payload");
});
it("builds an encoded view URL through Comfy's base-path resolver", () => {
const url = previewAssetUrl(
{ filename: "one two.png", subfolder: "a/b", type: "temp" },
(path) => `/comfy${path}`
);
expect(url).toBe(
"/comfy/view?filename=one+two.png&subfolder=a%2Fb&type=temp"
);
});
});
function validDocument(): object {
const image = { filename: "asset.png", subfolder: "", type: "temp" };
return {
version: 1,
source: { width: 10, height: 8 },
preview: { width: 10, height: 8, image },
atlas: { width: 4, height: 4, image },
regions: [
{
id: "seg-0001",
index: 0,
label: "subject",
confidence: 0.9,
area: 8,
color: "#f24236",
crop: { x: 1, y: 1, width: 4, height: 2 },
atlas: { x: 0, y: 0, width: 4, height: 2 }
}
]
};
}
+36
View File
@@ -0,0 +1,36 @@
// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it, vi } from "vitest";
import { SelectionModel } from "../src/selectionModel";
describe("SelectionModel", () => {
it("shares hover and pinned selection across inspector views", () => {
const model = new SelectionModel<string>();
const subscriber = vi.fn();
model.subscribe(subscriber);
model.hover(["small", "large"]);
model.selectNextCandidate();
model.clearHover();
expect(model.state()).toEqual({
hovered: null,
selected: "small",
candidates: [],
active: "small"
});
expect(subscriber).toHaveBeenCalledTimes(4);
});
it("cycles through overlapping candidates from the pinned region", () => {
const model = new SelectionModel<string>();
model.hover(["small", "large"]);
expect(model.selectNextCandidate()).toBe("small");
expect(model.selectNextCandidate()).toBe("large");
expect(model.selectNextCandidate()).toBe("small");
});
});
+41
View File
@@ -9,6 +9,7 @@ import type {
ComfySettingDefinition,
SettingValue
} from "../src/types";
import { vi } from "vitest";
export interface FakeComfySettingsApi {
definitions: Array<ComfySettingDefinition<SettingValue>>;
@@ -64,3 +65,43 @@ export function createJsonResponse(
...init
});
}
/** Install a deterministic 2D canvas surface for DOM inspector tests. */
export function installCanvasMock(maskValue = 255): void {
const context = {
globalAlpha: 1,
shadowColor: "",
shadowBlur: 0,
clearRect: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
getImageData: vi.fn((x: number, y: number, width: number, height: number) => {
void x;
void y;
const data = new Uint8ClampedArray(width * height * 4);
for (let offset = 0; offset < data.length; offset += 4) {
data[offset] = maskValue;
data[offset + 1] = maskValue;
data[offset + 2] = maskValue;
data[offset + 3] = 255;
}
return { data, width, height, colorSpace: "srgb" };
}),
putImageData: vi.fn(),
restore: vi.fn(),
save: vi.fn()
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context);
vi.stubGlobal(
"ImageData",
class {
readonly colorSpace = "srgb";
constructor(
readonly data: Uint8ClampedArray,
readonly width: number,
readonly height: number
) {}
}
);
}