feat(segmentation): add interactive SEGS preview
This commit is contained in:
@@ -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.")
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,),
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -49,6 +49,7 @@ BASE_NODE_IDS = [
|
||||
"SimpleSyrup.ScaleFactor",
|
||||
"SimpleSyrup.Seed",
|
||||
"SimpleSyrup.SimpleLoadAnima",
|
||||
"SimpleSyrup.SimplePreviewSEGS",
|
||||
"SimpleSyrup.SimpleLoadCheckpoint",
|
||||
"SimpleSyrup.SimpleVAEEncode",
|
||||
"SimpleSyrup.TagSEGSWithExternalLLM",
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
Vendored
+779
-2
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user