diff --git a/simple_syrup/domain/seg_visualization.py b/simple_syrup/domain/seg_visualization.py new file mode 100644 index 0000000..1f6ac20 --- /dev/null +++ b/simple_syrup/domain/seg_visualization.py @@ -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.") diff --git a/simple_syrup/domain/segs.py b/simple_syrup/domain/segs.py index b68158f..f295d74 100644 --- a/simple_syrup/domain/segs.py +++ b/simple_syrup/domain/segs.py @@ -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.""" diff --git a/simple_syrup/domain/segs_tiled_diffusion.py b/simple_syrup/domain/segs_tiled_diffusion.py index d3f528e..3a3d694 100644 --- a/simple_syrup/domain/segs_tiled_diffusion.py +++ b/simple_syrup/domain/segs_tiled_diffusion.py @@ -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, diff --git a/simple_syrup/nodes/simple_preview_segs.py b/simple_syrup/nodes/simple_preview_segs.py new file mode 100644 index 0000000..87e52ef --- /dev/null +++ b/simple_syrup/nodes/simple_preview_segs.py @@ -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,), + } diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index b282d50..79ebb24 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -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, diff --git a/simple_syrup/nodes_v3/legacy_node_wrappers.py b/simple_syrup/nodes_v3/legacy_node_wrappers.py index 4e075ad..b60fd20 100644 --- a/simple_syrup/nodes_v3/legacy_node_wrappers.py +++ b/simple_syrup/nodes_v3/legacy_node_wrappers.py @@ -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", diff --git a/simple_syrup/runtime/sam_region_overlay_renderer.py b/simple_syrup/runtime/sam_region_overlay_renderer.py index c1bebd4..e92d653 100644 --- a/simple_syrup/runtime/sam_region_overlay_renderer.py +++ b/simple_syrup/runtime/sam_region_overlay_renderer.py @@ -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.""" diff --git a/simple_syrup/runtime/seg_preview_assets.py b/simple_syrup/runtime/seg_preview_assets.py new file mode 100644 index 0000000..a78255f --- /dev/null +++ b/simple_syrup/runtime/seg_preview_assets.py @@ -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} diff --git a/simple_syrup/services/segs_detection_service.py b/simple_syrup/services/segs_detection_service.py index 345b599..bdb33af 100644 --- a/simple_syrup/services/segs_detection_service.py +++ b/simple_syrup/services/segs_detection_service.py @@ -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, diff --git a/simple_syrup/services/segs_from_sam_output_service.py b/simple_syrup/services/segs_from_sam_output_service.py index 20aab62..9a7ed92 100644 --- a/simple_syrup/services/segs_from_sam_output_service.py +++ b/simple_syrup/services/segs_from_sam_output_service.py @@ -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( diff --git a/simple_syrup/services/segs_output_service.py b/simple_syrup/services/segs_output_service.py index 94d0466..bd2d432 100644 --- a/simple_syrup/services/segs_output_service.py +++ b/simple_syrup/services/segs_output_service.py @@ -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) diff --git a/simple_syrup/services/simple_preview_segs_service.py b/simple_syrup/services/simple_preview_segs_service.py new file mode 100644 index 0000000..e574d33 --- /dev/null +++ b/simple_syrup/services/simple_preview_segs_service.py @@ -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) + ) diff --git a/tests/test_registration.py b/tests/test_registration.py index c3b842b..3f3ed5c 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -49,6 +49,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.ScaleFactor", "SimpleSyrup.Seed", "SimpleSyrup.SimpleLoadAnima", + "SimpleSyrup.SimplePreviewSEGS", "SimpleSyrup.SimpleLoadCheckpoint", "SimpleSyrup.SimpleVAEEncode", "SimpleSyrup.TagSEGSWithExternalLLM", diff --git a/tests/test_seg_preview_assets.py b/tests/test_seg_preview_assets.py new file mode 100644 index 0000000..bcffdb0 --- /dev/null +++ b/tests/test_seg_preview_assets.py @@ -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), + ), + ), + ) diff --git a/tests/test_seg_visualization.py b/tests/test_seg_visualization.py new file mode 100644 index 0000000..6e78967 --- /dev/null +++ b/tests/test_seg_visualization.py @@ -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, + ) diff --git a/tests/test_segs_from_sam_output_service.py b/tests/test_segs_from_sam_output_service.py index c88837f..5a0d3ab 100644 --- a/tests/test_segs_from_sam_output_service.py +++ b/tests/test_segs_from_sam_output_service.py @@ -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)) diff --git a/tests/test_segs_output_service.py b/tests/test_segs_output_service.py index 094ccea..6367f0d 100644 --- a/tests/test_segs_output_service.py +++ b/tests/test_segs_output_service.py @@ -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.""" diff --git a/tests/test_simple_preview_segs_node.py b/tests/test_simple_preview_segs_node.py new file mode 100644 index 0000000..3732866 --- /dev/null +++ b/tests/test_simple_preview_segs_node.py @@ -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] diff --git a/tests/test_simple_preview_segs_service.py b/tests/test_simple_preview_segs_service.py new file mode 100644 index 0000000..1394302 --- /dev/null +++ b/tests/test_simple_preview_segs_service.py @@ -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, + ) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index 43db19a..ef1ae03 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -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); diff --git a/web/src/imageViewport.ts b/web/src/imageViewport.ts new file mode 100644 index 0000000..2b369f9 --- /dev/null +++ b/web/src/imageViewport.ts @@ -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)); +} diff --git a/web/src/interactiveInspector.ts b/web/src/interactiveInspector.ts new file mode 100644 index 0000000..9a0f8be --- /dev/null +++ b/web/src/interactiveInspector.ts @@ -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 { + readonly element: HTMLElement; + setLoading(): void; + prepare(document: TDocument): Promise; + showError(message: string): void; + dispose(): void; +} + +/** Publish only the latest asynchronously prepared execution document. */ +export class InteractiveInspectorController { + private version = 0; + private disposed = false; + private committed: PreparedInspectorState | undefined; + + constructor( + private readonly view: AsyncInspectorView, + 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); + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 8f62569..742dcc3 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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); diff --git a/web/src/maskAtlas.ts b/web/src/maskAtlas.ts new file mode 100644 index 0000000..886bc8f --- /dev/null +++ b/web/src/maskAtlas.ts @@ -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) + ]; +} diff --git a/web/src/segPreviewInspector.ts b/web/src/segPreviewInspector.ts new file mode 100644 index 0000000..b66c81a --- /dev/null +++ b/web/src/segPreviewInspector.ts @@ -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; + +interface CommittedPreview { + document: SegPreviewDocument; + image: HTMLImageElement; + atlas: MaskAtlas; + viewport: ImageViewport; + masks: Map; + selection: SelectionModel; +} + +/** Render the interactive SEGS inspector inside Comfy's native DOM widget. */ +export class SegPreviewInspector + implements AsyncInspectorView +{ + 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(); + + 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 { + 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(); + 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): 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(value: T | undefined): T { + if (!value) throw new Error("Simple Preview SEGS has no committed document."); + return value; +} + +function loadPreviewImage(url: string): Promise { + 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; +} diff --git a/web/src/segPreviewNode.ts b/web/src/segPreviewNode.ts new file mode 100644 index 0000000..744ad75 --- /dev/null +++ b/web/src/segPreviewNode.ts @@ -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 + >(); + + 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) { + 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; + return ( + node.constructor?.comfyClass === SIMPLE_PREVIEW_SEGS_NODE_ID && + typeof node.addDOMWidget === "function" + ); +} diff --git a/web/src/segPreviewTypes.ts b/web/src/segPreviewTypes.ts new file mode 100644 index 0000000..4c1362e --- /dev/null +++ b/web/src/segPreviewTypes.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/web/src/selectionModel.ts b/web/src/selectionModel.ts new file mode 100644 index 0000000..bcffc25 --- /dev/null +++ b/web/src/selectionModel.ts @@ -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 { + hovered: TId | null; + selected: TId | null; + candidates: readonly TId[]; + active: TId | null; +} + +export type SelectionSubscriber = (state: SelectionState) => void; + +/** Coordinate hover and pinned selection across inspector representations. */ +export class SelectionModel { + private hovered: TId | null = null; + private selected: TId | null = null; + private candidates: readonly TId[] = []; + private readonly subscribers = new Set>(); + + /** Subscribe to selection changes and receive the current state immediately. */ + subscribe(subscriber: SelectionSubscriber): () => 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 { + 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); + } +} diff --git a/web/src/types.ts b/web/src/types.ts index 628c659..7650889 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; @@ -53,4 +57,7 @@ export interface ComfyExtension { name: string; setup?(app: ComfyApp): void | Promise; nodeCreated?(node: unknown): void | Promise; + onNodeOutputsUpdated?( + outputs: Record + ): void | Promise; } diff --git a/web/tests/imageViewport.test.ts b/web/tests/imageViewport.test.ts new file mode 100644 index 0000000..eed3b30 --- /dev/null +++ b/web/tests/imageViewport.test.ts @@ -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 }); + }); +}); diff --git a/web/tests/interactiveInspector.test.ts b/web/tests/interactiveInspector.test.ts new file mode 100644 index 0000000..5521d3c --- /dev/null +++ b/web/tests/interactiveInspector.test.ts @@ -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(); + const second = deferred(); + 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(); + 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 { + disposeSpy: ReturnType void>>; +} + +function fakeView( + preparations: Array> +): 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 void>>; + disposeSpy: ReturnType void>>; +} + +function preparedState(): TestPreparedState { + const commitSpy = vi.fn<() => void>(); + const disposeSpy = vi.fn<() => void>(); + return { + commit: () => { + commitSpy(); + }, + dispose: () => { + disposeSpy(); + }, + commitSpy, + disposeSpy + }; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolver: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolver = resolve; + }); + return { + promise, + resolve(value: T): void { + if (!resolver) throw new Error("Deferred resolver was not initialized."); + resolver(value); + } + }; +} diff --git a/web/tests/maskAtlas.test.ts b/web/tests/maskAtlas.test.ts new file mode 100644 index 0000000..ebd77bb --- /dev/null +++ b/web/tests/maskAtlas.test.ts @@ -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; +} diff --git a/web/tests/segPreviewInspector.test.ts b/web/tests/segPreviewInspector.test.ts new file mode 100644 index 0000000..8f9ed7b --- /dev/null +++ b/web/tests/segPreviewInspector.test.ts @@ -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; +} diff --git a/web/tests/segPreviewNode.test.ts b/web/tests/segPreviewNode.test.ts new file mode 100644 index 0000000..8e4d96a --- /dev/null +++ b/web/tests/segPreviewNode.test.ts @@ -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 [number, number]>>; + setSize: ReturnType 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["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; +} diff --git a/web/tests/segPreviewTypes.test.ts b/web/tests/segPreviewTypes.test.ts new file mode 100644 index 0000000..fa859d1 --- /dev/null +++ b/web/tests/segPreviewTypes.test.ts @@ -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 } + } + ] + }; +} diff --git a/web/tests/selectionModel.test.ts b/web/tests/selectionModel.test.ts new file mode 100644 index 0000000..d03b292 --- /dev/null +++ b/web/tests/selectionModel.test.ts @@ -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(); + 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(); + model.hover(["small", "large"]); + + expect(model.selectNextCandidate()).toBe("small"); + expect(model.selectNextCandidate()).toBe("large"); + expect(model.selectNextCandidate()).toBe("small"); + }); +}); diff --git a/web/tests/testUtils.ts b/web/tests/testUtils.ts index 528356a..d4c7e76 100644 --- a/web/tests/testUtils.ts +++ b/web/tests/testUtils.ts @@ -9,6 +9,7 @@ import type { ComfySettingDefinition, SettingValue } from "../src/types"; +import { vi } from "vitest"; export interface FakeComfySettingsApi { definitions: Array>; @@ -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 + ) {} + } + ); +}