feat(sampling): refine sampler options and inversion controls

This commit is contained in:
Artificial Sweetener
2026-10-02 12:51:27 -04:00
parent e4eabfefd5
commit ab7cebcebc
40 changed files with 1095 additions and 221 deletions
-3
View File
@@ -27,14 +27,11 @@ class NoiseInversionOptions:
steps: int = 2
switch_fraction: float = 0.75
finishing_steps: int = 1
finishing_method: InversionMethod = "euler"
def __post_init__(self) -> None:
"""Reject invalid or internally incomplete inversion recipes."""
if self.method not in INVERSION_METHODS:
raise ValueError("Inversion method must be euler or heun.")
if self.finishing_method not in INVERSION_METHODS:
raise ValueError("Inversion finishing method must be euler or heun.")
if (
not math.isfinite(self.resolution_scale)
or not 0 < self.resolution_scale <= 1
+24 -17
View File
@@ -25,7 +25,6 @@ class TilingOptions:
overlap: int = 32
batch_size: int = 4
differential_diffusion: bool = False
segs: object | None = None
def __post_init__(self) -> None:
"""Reject tile settings that cannot form a bounded prediction plan."""
@@ -49,19 +48,19 @@ class TilingOptions:
@dataclass(frozen=True, slots=True)
class ContextualDiffusionOptions:
"""Add global scene context to the authoritative local tile predictions.
With no Tiling options, the context size also supplies the square local
tile size, using 32 latent pixels of overlap and a batch size of four.
"""
"""Own global context and square local sampling geometry independently of tiling."""
context_size: int = 96
global_weight: float = 1.0
global_steps: int = 1
global_decay: float = 0.5
diffusion_mode: str = "multidiffusion"
overlap: int = 32
batch_size: int = 4
differential_diffusion: bool = False
def __post_init__(self) -> None:
"""Reject unsupported global-context geometry and schedule values."""
"""Reject invalid global schedules and square local sampling settings."""
if type(self.context_size) is not int or not 16 <= self.context_size <= 512:
raise ValueError("Context size must be between 16 and 512 latent pixels.")
if not math.isfinite(self.global_weight) or not 0 <= self.global_weight <= 2:
@@ -70,20 +69,29 @@ class ContextualDiffusionOptions:
raise ValueError("Global context steps must be a nonnegative integer.")
if not math.isfinite(self.global_decay) or not 0 <= self.global_decay <= 1:
raise ValueError("Global context decay must be between 0 and 1.")
self.local_tiling()
def local_tiling(self) -> TilingOptions:
"""Use context size for both dimensions of the sole local sampling plan."""
return TilingOptions(
diffusion_mode=self.diffusion_mode,
width=self.context_size,
height=self.context_size,
overlap=self.overlap,
batch_size=self.batch_size,
differential_diffusion=self.differential_diffusion,
)
@dataclass(frozen=True, slots=True)
class AttentionCouplingOptions:
"""Carry regional attention inputs without preparing or mutating a model."""
"""Configure regional attention strength without binding masks or a model."""
region_masks: object
regional_prompt_weight: float = 1.0
region_mask_feather: int = 0
def __post_init__(self) -> None:
"""Require a mask source and valid regional attention strengths."""
if self.region_masks is None:
raise ValueError("Attention Coupling requires region masks.")
"""Require valid regional attention strengths and feathering controls."""
validate_regional_prompt_weight(self.regional_prompt_weight)
if type(self.region_mask_feather) is not int or self.region_mask_feather < 0:
raise ValueError("Region mask feather must be a nonnegative integer.")
@@ -143,13 +151,12 @@ class SamplerOptions:
def append_sampler_capability(
options: SamplerOptions | None, capability: SamplerCapability
options: SamplerOptions | None, capability: SamplerCapability | None
) -> SamplerOptions:
"""Start an options chain or append to a validated incoming connection."""
"""Append a capability or pass through a disabled contribution after validation."""
if options is not None and not isinstance(options, SamplerOptions):
raise TypeError(
"Options input must be a SimpleSyrup sampler options connection."
)
return (options if options is not None else SamplerOptions()).with_capability(
capability
)
current = options if options is not None else SamplerOptions()
return current if capability is None else current.with_capability(capability)
-3
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
from typing import Any, ClassVar
from ..domain.noise_inversion import NoiseInversionOptions
from ..runtime import sampling_samplers, sampling_schedulers
from ..services.ksampler_sampling_service import KSamplerSamplingService
from . import tooltips
@@ -111,7 +110,6 @@ class KSamplerExtras:
negative: Any | None = None,
latent_image: Latent | None = None,
denoise: float = 1.0,
noise_inversion: NoiseInversionOptions | None = None,
) -> tuple[Latent]:
"""Sample a latent with ComfyUI samplers and extra scheduler sigmas."""
@@ -128,6 +126,5 @@ class KSamplerExtras:
negative=negative,
latent_image=latent_image,
denoise=denoise,
noise_inversion=noise_inversion,
)
return (output,)
@@ -23,11 +23,11 @@ from .sampler_options_schema import (
class AttentionCouplingOptionsV3(OptionsNodeBase):
"""Pair global-first conditioning on the sampler with ordered region masks."""
"""Configure the sampler's regional attention strength and mask feathering."""
@classmethod
def define_schema(cls) -> Any:
"""Expose regional masks with established strength and feathering controls."""
"""Expose strength and feathering without binding region payloads."""
controls = attention_coupling_ksampler_inputs(COMFY_IO)
return COMFY_IO.Schema(
node_id="SimpleSyrup.AttentionCouplingOptions",
@@ -42,8 +42,7 @@ class AttentionCouplingOptionsV3(OptionsNodeBase):
*[
control
for control in controls
if control.id
in {"region_masks", "regional_prompt_weight", "region_mask_feather"}
if control.id in {"regional_prompt_weight", "region_mask_feather"}
],
],
outputs=[options_output(COMFY_IO)],
@@ -52,7 +51,6 @@ class AttentionCouplingOptionsV3(OptionsNodeBase):
@classmethod
def execute(
cls,
region_masks: object,
regional_prompt_weight: float = 1.0,
region_mask_feather: int = 0,
options: SamplerOptions | None = None,
@@ -62,7 +60,6 @@ class AttentionCouplingOptionsV3(OptionsNodeBase):
append_sampler_capability(
options,
AttentionCouplingOptions(
region_masks=region_masks,
regional_prompt_weight=regional_prompt_weight,
region_mask_feather=region_mask_feather,
),
@@ -2,7 +2,7 @@
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Add whole-scene context without introducing a second local tiling engine."""
"""Configure complete contextual sampling with one square local context plan."""
from __future__ import annotations
@@ -27,29 +27,35 @@ class ContextualDiffusionOptionsV3(OptionsNodeBase):
@classmethod
def define_schema(cls) -> Any:
"""Expose global controls independently of the optional Tiling contribution."""
controls = contextual_diffusion_inputs(COMFY_IO)
"""Append local controls after existing widgets to preserve saved values."""
controls = {
control.id: control for control in contextual_diffusion_inputs(COMFY_IO)
}
return COMFY_IO.Schema(
node_id="SimpleSyrup.ContextualDiffusionOptions",
display_name="Contextual Diffusion Options",
category="SimpleSyrup/Sampling/Options",
description=(
"Adds global scene context to local tiles; Tiling options "
"can independently configure local geometry and blending."
"Samples local contexts with global scene guidance; "
"takes precedence over connected Tiling Options."
),
inputs=[
options_input(COMFY_IO),
*[
control
for control in controls
if control.id
in {
"latent_context_size",
"global_weight",
"global_steps",
"global_decay",
}
],
controls["latent_context_size"],
controls["global_weight"],
controls["global_steps"],
controls["global_decay"],
controls["diffusion_mode"],
controls["latent_context_overlap"],
controls["latent_context_batch_size"],
COMFY_IO.Boolean.Input(
"differential_diffusion",
default=False,
tooltip=(
"Uses the noise mask to vary denoising strength spatially; "
"preserves existing model mask behavior."
),
),
],
outputs=[options_output(COMFY_IO)],
)
@@ -62,8 +68,12 @@ class ContextualDiffusionOptionsV3(OptionsNodeBase):
global_steps: int = 1,
global_decay: float = 0.5,
options: SamplerOptions | None = None,
diffusion_mode: str = "multidiffusion",
latent_context_overlap: int = 32,
latent_context_batch_size: int = 4,
differential_diffusion: bool = False,
) -> tuple[SamplerOptions]:
"""Append immutable global-context settings in any chain position."""
"""Append complete context settings without inheriting a Tiling contribution."""
return (
append_sampler_capability(
options,
@@ -72,6 +82,10 @@ class ContextualDiffusionOptionsV3(OptionsNodeBase):
global_weight=global_weight,
global_steps=global_steps,
global_decay=global_decay,
diffusion_mode=diffusion_mode,
overlap=latent_context_overlap,
batch_size=latent_context_batch_size,
differential_diffusion=differential_diffusion,
),
),
)
+22 -1
View File
@@ -24,7 +24,7 @@ class KSamplerV3(OptionsNodeBase):
@classmethod
def define_schema(cls) -> Any:
"""Declare sampling controls and an optional capabilities connection."""
"""Declare sampling controls, capabilities and optional spatial regions."""
return COMFY_IO.Schema(
node_id="SimpleSyrup.KSampler",
display_name="KSampler (SimpleSyrup)",
@@ -36,6 +36,23 @@ class KSamplerV3(OptionsNodeBase):
inputs=[
*ksampler_inputs(COMFY_IO, steps_default=20, cfg_default=8.0),
options_input(COMFY_IO),
COMFY_IO.SEGS.Input(
"segs",
optional=True,
tooltip=(
"Guides local sampling regions when Tiling or Contextual "
"Diffusion options are connected; ignored otherwise."
),
),
COMFY_IO.Mask.Input(
"region_masks",
optional=True,
tooltip=(
"Ordered masks paired with global-first conditioning batches "
"when Attention Coupling options are connected; "
"ignored otherwise."
),
),
],
outputs=[
COMFY_IO.Latent.Output(
@@ -58,6 +75,8 @@ class KSamplerV3(OptionsNodeBase):
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
options: SamplerOptions | None = None,
segs: object | None = None,
region_masks: object | None = None,
) -> tuple[dict[str, Any]]:
"""Delegate sampling without mutating capability configuration."""
if latent_image is None:
@@ -75,5 +94,7 @@ class KSamplerV3(OptionsNodeBase):
latent_image=latent_image,
denoise=denoise,
options=options,
segs=segs,
region_masks=region_masks,
),
)
@@ -102,13 +102,11 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
ATTENTION_COUPLING_REGIONAL_PROMPT_WEIGHT_DEFAULT
),
region_mask_feather: int = 0,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any]]:
"""Delegate ordinary or regional sampling to the routing service."""
@@ -116,13 +114,11 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
raise TypeError("KSampler Attention Coupling requires latent_image.")
output = cls.sampling_service_class().sample(
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
model=model,
seed=seed,
@@ -114,13 +114,11 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
global_steps: int = 1,
global_decay: float = 0.5,
segs: object | None = None,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any], object]:
"""Delegate the complete request to the combined application service."""
@@ -155,13 +153,11 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
global_decay=global_decay,
segs=segs,
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
)
return result.latent, result.contexts
@@ -106,13 +106,11 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
region_masks: object | None = None,
regional_prompt_weight: float = 0.5,
region_mask_feather: int = 0,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any], object]:
"""Delegate Contextual Diffusion sampling to its application service."""
@@ -141,13 +139,11 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
regional_prompt_weight=regional_prompt_weight,
region_mask_feather=region_mask_feather,
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
)
return result.latent, result.contexts
@@ -80,13 +80,11 @@ class KSamplerPromptByRegionV3(_ComfyNodeBase):
region_mask_feather: int = 0,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any]]:
"""Assemble regional conditioning and sample the full latent."""
@@ -105,13 +103,11 @@ class KSamplerPromptByRegionV3(_ComfyNodeBase):
)
output = cls.sampling_service_class().sample(
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
model=model,
seed=seed,
@@ -92,13 +92,11 @@ class KSamplerPromptByTiledRegionV3(_ComfyNodeBase):
latent_tile_height: int = 128,
latent_tile_overlap: int = 16,
latent_tile_batch_size: int = 4,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any]]:
"""Assemble regional conditioning and sample overlapping latent tiles."""
@@ -117,13 +115,11 @@ class KSamplerPromptByTiledRegionV3(_ComfyNodeBase):
)
output = cls.sampling_service_class().sample(
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
diffusion_mode=diffusion_mode,
model=model,
@@ -107,13 +107,11 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
latent_tile_height: int = 128,
latent_tile_overlap: int = 16,
latent_tile_batch_size: int = 4,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
region_masks: object | None = None,
regional_prompt_weight: float = (
ATTENTION_COUPLING_REGIONAL_PROMPT_WEIGHT_DEFAULT
@@ -126,13 +124,11 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
raise TypeError("KSampler Tiled Attention Coupling requires latent_image.")
output = cls.sampling_service_class().sample(
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
diffusion_mode=diffusion_mode,
model=model,
@@ -98,13 +98,11 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
region_masks: object | None = None,
regional_prompt_weight: float = 0.5,
region_mask_feather: int = 0,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any]]:
"""Delegate tiled diffusion sampling to its application service."""
@@ -132,13 +130,11 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
regional_prompt_weight=regional_prompt_weight,
region_mask_feather=region_mask_feather,
noise_inversion=inversion_from_controls(
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
),
)
return (output,)
@@ -9,7 +9,6 @@ from __future__ import annotations
from typing import Any
from ..nodes.detailer_input_adapters import (
bool_input,
float_input,
int_input,
str_input,
@@ -27,7 +26,7 @@ class LegacyInversionNodeV3Adapter(LegacyNodeV3Adapter):
@classmethod
def define_schema(cls) -> Any:
"""Append optional controls without moving any persisted workflow input."""
"""Append the shared five inversion controls after the sampler inputs."""
schema = super().define_schema()
schema.inputs.extend(noise_inversion_inputs(COMFY_IO, convenience=True))
return schema
@@ -39,12 +38,6 @@ class LegacyInversionNodeV3Adapter(LegacyNodeV3Adapter):
list_mode = bool(getattr(cls.LEGACY_NODE_CLASS, "INPUT_IS_LIST", False))
operation = cls.DISPLAY_NAME
inversion = inversion_from_controls(
noise_inversion_enabled=bool_input(
values.pop("noise_inversion_enabled", False),
"noise_inversion_enabled",
list_mode,
operation,
),
inversion_method=str_input(
values.pop("inversion_method", "euler"),
"inversion_method",
@@ -75,12 +68,6 @@ class LegacyInversionNodeV3Adapter(LegacyNodeV3Adapter):
list_mode,
operation,
),
inversion_finishing_method=str_input(
values.pop("inversion_finishing_method", "euler"),
"inversion_finishing_method",
list_mode,
operation,
),
)
values["noise_inversion"] = inversion
return super().execute(**values)
@@ -78,7 +78,7 @@ class GroundingDINOModelLoaderV3(LegacyNodeV3Adapter):
DISPLAY_NAME = "GroundingDINO Model Loader"
class KSamplerExtrasV3(LegacyInversionNodeV3Adapter):
class KSamplerExtrasV3(LegacyNodeV3Adapter):
"""Expose KSampler Extras through Comfy v3 only."""
LEGACY_NODE_CLASS = KSamplerExtras
@@ -45,18 +45,14 @@ class NoiseInversionOptionsV3(OptionsNodeBase):
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
options: SamplerOptions | None = None,
) -> tuple[SamplerOptions]:
"""Append validated inversion configuration without preparing any model."""
"""Append inversion or pass through at zero steps without preparing a model."""
inversion = inversion_from_controls(
noise_inversion_enabled=True,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
)
assert inversion is not None
return (append_sampler_capability(options, inversion),)
@@ -51,31 +51,15 @@ def options_output(comfy_io: Any) -> Any:
def noise_inversion_inputs(comfy_io: Any, *, convenience: bool = False) -> list[Any]:
"""Use the accepted two coarse plus one finishing-step recipe in both APIs."""
enabled = (
[
comfy_io.Boolean.Input(
"noise_inversion_enabled",
default=False,
optional=True,
tooltip=(
"Derives starting noise from the input image; "
"adds inversion work before denoising."
),
)
]
if convenience
else []
)
"""Default to the accepted recipe and use zero steps to disable inversion."""
return [
*enabled,
comfy_io.Combo.Input(
"inversion_method",
options=list(INVERSION_METHODS),
default="euler",
optional=convenience,
tooltip=(
"Euler uses one model evaluation per inversion step; "
"Applies to both inversion stages; Euler uses one evaluation per step, "
"Heun uses two for greater accuracy."
),
),
@@ -94,12 +78,12 @@ def noise_inversion_inputs(comfy_io: Any, *, convenience: bool = False) -> list[
comfy_io.Int.Input(
"inversion_steps",
default=2,
min=1,
min=0,
max=64,
optional=convenience,
tooltip=(
"Steps at the selected inversion resolution; "
"more steps cost more model evaluations."
"Steps at the selected inversion resolution; 0 disables all inversion, "
"including finishing. More steps cost more model evaluations."
),
),
comfy_io.Float.Input(
@@ -125,33 +109,21 @@ def noise_inversion_inputs(comfy_io: Any, *, convenience: bool = False) -> list[
"0 finishes entirely at reduced size."
),
),
comfy_io.Combo.Input(
"inversion_finishing_method",
options=list(INVERSION_METHODS),
default="euler",
optional=convenience,
tooltip=(
"Method for full-resolution finishing; "
"Euler costs one evaluation per step, Heun two."
),
),
]
def inversion_from_controls(
*,
noise_inversion_enabled: bool,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> NoiseInversionOptions | None:
"""Use domain validation when inversion is selected."""
if type(noise_inversion_enabled) is not bool:
raise TypeError("noise_inversion_enabled must be a boolean.")
if not noise_inversion_enabled:
"""Disable all stages at zero steps or construct a shared-method recipe."""
if type(inversion_steps) is not int or not 0 <= inversion_steps <= 64:
raise ValueError("Inversion steps must be an integer between 0 and 64.")
if inversion_steps == 0:
return None
return NoiseInversionOptions(
method=cast(InversionMethod, inversion_method),
@@ -159,5 +131,4 @@ def inversion_from_controls(
steps=inversion_steps,
switch_fraction=inversion_switch_fraction,
finishing_steps=inversion_finishing_steps,
finishing_method=cast(InversionMethod, inversion_finishing_method),
)
+3 -13
View File
@@ -23,7 +23,7 @@ from .sampler_options_schema import (
class TilingOptionsV3(OptionsNodeBase):
"""Add bounded local tiles, blend policy and optional semantic guidance."""
"""Add bounded local tiles, blend policy and mask-dependent denoising."""
@classmethod
def define_schema(cls) -> Any:
@@ -33,8 +33,8 @@ class TilingOptionsV3(OptionsNodeBase):
display_name="Tiling Options",
category="SimpleSyrup/Sampling/Options",
description=(
"Samples bounded local tiles; with Contextual Diffusion, "
"configures its sole local tile plan."
"Samples bounded local tiles; ignored when "
"Contextual Diffusion Options is connected."
),
inputs=[
options_input(COMFY_IO),
@@ -47,14 +47,6 @@ class TilingOptionsV3(OptionsNodeBase):
"preserves existing model mask behavior."
),
),
COMFY_IO.SEGS.Input(
"segs",
optional=True,
tooltip=(
"Optional image regions that guide local tile ownership "
"and boundaries."
),
),
],
outputs=[options_output(COMFY_IO)],
)
@@ -68,7 +60,6 @@ class TilingOptionsV3(OptionsNodeBase):
latent_tile_overlap: int = 16,
latent_tile_batch_size: int = 4,
differential_diffusion: bool = False,
segs: object | None = None,
options: SamplerOptions | None = None,
) -> tuple[SamplerOptions]:
"""Append validated tiling without changing the incoming chain."""
@@ -82,7 +73,6 @@ class TilingOptionsV3(OptionsNodeBase):
overlap=latent_tile_overlap,
batch_size=latent_tile_batch_size,
differential_diffusion=differential_diffusion,
segs=segs,
),
),
)
+1 -1
View File
@@ -266,7 +266,7 @@ def invert_sampling_noise(
options.finishing_steps,
endpoint,
"full_finish",
options.finishing_method,
options.method,
)
sigma = torch.tensor(target)
@@ -10,10 +10,15 @@ from typing import Any, TypedDict
import torch
from ..domain.attention_coupling_request import (
AttentionCouplingRequestMode,
classify_attention_coupling_request,
)
from ..domain.sampler_options import SamplerOptions, TilingOptions
from ..runtime import sampling_schedulers
from ..runtime.noise_inversion import validate_inversion_target
from ..runtime.tiled_sampling_validation import validate_sampling_controls
from ..shared.logging import get_logger
from .attention_coupling_sampling_service import AttentionCouplingSamplingService
from .contextual_attention_coupling_sampling_service import (
ContextualAttentionCouplingSamplingService,
@@ -25,6 +30,8 @@ from .tiled_attention_coupling_sampling_service import (
)
from .tiled_diffusion_sampling_service import TiledDiffusionSamplingService
LOGGER = get_logger(__name__)
class SamplingArguments(TypedDict):
"""Narrow controls while retaining dynamic host MODEL and tensor payloads."""
@@ -81,8 +88,10 @@ class SamplerOptionsSamplingService:
latent_image: dict[str, Any],
denoise: float,
options: SamplerOptions | None = None,
segs: object | None = None,
region_masks: object | None = None,
) -> dict[str, Any]:
"""Apply regional attention once, then one spatial authority and inversion."""
"""Admit connected region data only through its enabled sampling capability."""
if options is not None and not isinstance(options, SamplerOptions):
raise TypeError(
"KSampler options must come from SimpleSyrup options nodes."
@@ -90,12 +99,13 @@ class SamplerOptionsSamplingService:
configured = options if options is not None else SamplerOptions()
context = configured.contextual_diffusion
tiling = configured.tiling
if context is not None and tiling is None:
tiling = TilingOptions(
width=context.context_size,
height=context.context_size,
overlap=min(32, context.context_size - 1),
)
if context is not None:
if tiling is not None:
LOGGER.warning(
"Contextual Diffusion takes precedence; Tiling Options ignored.",
extra={"node_id": "SimpleSyrup.KSampler"},
)
tiling = context.local_tiling()
arguments: SamplingArguments = {
"model": model,
"seed": seed,
@@ -110,6 +120,14 @@ class SamplerOptionsSamplingService:
}
self._preflight(arguments, configured, tiling)
attention = configured.attention_coupling
if (
attention is not None
and classify_attention_coupling_request(
positive=positive, negative=negative, region_masks=region_masks
)
is AttentionCouplingRequestMode.BYPASS
):
attention = None
inversion = configured.noise_inversion
if context is not None:
assert tiling is not None
@@ -126,10 +144,10 @@ class SamplerOptionsSamplingService:
result = ContextualAttentionCouplingSamplingService().sample(
**arguments,
**contextual_arguments,
region_masks=attention.region_masks,
region_masks=region_masks,
regional_prompt_weight=attention.regional_prompt_weight,
region_mask_feather=attention.region_mask_feather,
segs=tiling.segs,
segs=segs,
tiling=tiling,
noise_inversion=inversion,
)
@@ -137,7 +155,7 @@ class SamplerOptionsSamplingService:
result = ContextualDiffusionSamplingService().sample(
**arguments,
**contextual_arguments,
segs=tiling.segs,
segs=segs,
tiling=tiling,
noise_inversion=inversion,
)
@@ -155,22 +173,22 @@ class SamplerOptionsSamplingService:
return TiledAttentionCouplingSamplingService().sample(
**arguments,
**tiled_arguments,
region_masks=attention.region_masks,
region_masks=region_masks,
regional_prompt_weight=attention.regional_prompt_weight,
region_mask_feather=attention.region_mask_feather,
segs=tiling.segs,
segs=segs,
noise_inversion=inversion,
)
return TiledDiffusionSamplingService().sample(
**arguments,
**tiled_arguments,
segs=tiling.segs,
segs=segs,
noise_inversion=inversion,
)
if attention is not None:
return AttentionCouplingSamplingService().sample(
**arguments,
region_masks=attention.region_masks,
region_masks=region_masks,
regional_prompt_weight=attention.regional_prompt_weight,
region_mask_feather=attention.region_mask_feather,
noise_inversion=inversion,
@@ -17,7 +17,6 @@ from simple_syrup.nodes_v3.legacy_inversion_node_adapter import (
from simple_syrup.nodes_v3.legacy_node_wrappers import (
DetailSEGSAsRegionsV3,
DetailSEGSByScaleFactorTiledDiffusionV3,
KSamplerExtrasV3,
)
@@ -52,26 +51,26 @@ class RecordingAdapter(LegacyInversionNodeV3Adapter):
@pytest.mark.parametrize("list_mode", [False, True])
def test_inversion_controls_normalize_and_default_off(
def test_inversion_controls_normalize_and_default_on(
monkeypatch: pytest.MonkeyPatch,
list_mode: bool,
) -> None:
"""Preserve existing execution while constructing the accepted recipe on request."""
"""Default to the accepted recipe and normalize zero-step disable in list mode."""
monkeypatch.setattr(RecordingImplementation, "INPUT_IS_LIST", list_mode)
text = ["prompt"] if list_mode else "prompt"
output, disabled = RecordingAdapter.execute(text=text)
assert output == text and disabled is None
_, enabled = RecordingAdapter.execute(
output, enabled = RecordingAdapter.execute(text=text)
assert output == text and enabled == NoiseInversionOptions()
_, disabled = RecordingAdapter.execute(
text=text,
noise_inversion_enabled=[True] if list_mode else True,
inversion_steps=[0] if list_mode else 0,
)
assert enabled == NoiseInversionOptions()
assert disabled is None
@pytest.mark.parametrize(
"controls",
[
{"inversion_steps": 0},
{"inversion_steps": -1},
{"inversion_resolution_scale": 0},
{"inversion_method": "fireflow"},
{"inversion_switch_fraction": 1},
@@ -80,20 +79,20 @@ def test_inversion_controls_normalize_and_default_off(
def test_invalid_selected_inversion_controls_fail(controls: dict[str, Any]) -> None:
"""Apply domain validation instead of handing malformed controls to a sampler."""
with pytest.raises(ValueError):
RecordingAdapter.execute(
text="prompt", noise_inversion_enabled=True, **controls
)
RecordingAdapter.execute(text="prompt", **controls)
@pytest.mark.parametrize(
"node",
[KSamplerExtrasV3, DetailSEGSAsRegionsV3, DetailSEGSByScaleFactorTiledDiffusionV3],
[DetailSEGSAsRegionsV3, DetailSEGSByScaleFactorTiledDiffusionV3],
)
def test_existing_nodes_append_optional_inversion_without_reordering(node: Any) -> None:
"""Preserve every serialized socket position before seven optional new controls."""
"""Preserve sampler socket ordering before five optional inversion controls."""
schema = node.define_schema()
order = node.WORKFLOW_INPUT_ORDER
assert [item.id for item in schema.inputs[: len(order)]] == list(order)
new = schema.inputs[len(order) :]
assert len(new) == 7 and all(item.optional and item.tooltip for item in new)
assert new[0].id == "noise_inversion_enabled" and new[0].default is False
assert len(new) == 5 and all(item.optional and item.tooltip for item in new)
assert new[0].id == "inversion_method" and new[0].default == "euler"
steps = next(item for item in new if item.id == "inversion_steps")
assert steps.default == 2 and steps.min == 0
@@ -70,13 +70,11 @@ def test_schemas_expose_exact_names_and_shared_regional_contract() -> None:
"region_mask_feather",
"latent_image",
"denoise",
"noise_inversion_enabled",
"inversion_method",
"inversion_resolution_scale",
"inversion_steps",
"inversion_switch_fraction",
"inversion_finishing_steps",
"inversion_finishing_method",
]
assert tiled_ids[:13] == normal_ids[:13]
assert tiled_ids[13:18] == [
@@ -103,13 +101,11 @@ def test_schemas_expose_exact_names_and_shared_regional_contract() -> None:
"regional_prompt_weight": 0.5,
"region_mask_feather": 0,
"denoise": 1.0,
"noise_inversion_enabled": False,
"inversion_method": "euler",
"inversion_resolution_scale": 0.5,
"inversion_steps": 2,
"inversion_switch_fraction": 0.75,
"inversion_finishing_steps": 1,
"inversion_finishing_method": "euler",
}
tiled_defaults = {
value.id: value.default for value in tiled.inputs if hasattr(value, "default")
@@ -0,0 +1,91 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Verify CD owns complete local settings without separate tile dimensions."""
from __future__ import annotations
from typing import Any
import pytest
from simple_syrup.domain.sampler_options import (
ContextualDiffusionOptions,
TilingOptions,
)
from simple_syrup.nodes_v3.contextual_diffusion_options import (
ContextualDiffusionOptionsV3,
)
def test_cd_schema_exposes_controls_without_sampling_payloads() -> None:
"""Keep context size authoritative while exposing all other spatial choices."""
inputs = {
value.id: value for value in ContextualDiffusionOptionsV3.define_schema().inputs
}
assert set(inputs) == {
"options",
"diffusion_mode",
"latent_context_size",
"latent_context_overlap",
"latent_context_batch_size",
"global_weight",
"global_steps",
"global_decay",
"differential_diffusion",
}
assert inputs["options"].optional
assert inputs["diffusion_mode"].default == "multidiffusion"
assert inputs["latent_context_overlap"].default == 32
assert inputs["latent_context_batch_size"].default == 4
assert inputs["differential_diffusion"].default is False
assert all(value.tooltip for value in inputs.values())
def test_existing_cd_widget_values_keep_their_serialized_positions() -> None:
"""Append new widgets so older workflows retain their context and global values."""
inputs = ContextualDiffusionOptionsV3.define_schema().inputs
widgets = [value.id for value in inputs if value.id not in {"options", "segs"}]
assert widgets[:4] == [
"latent_context_size",
"global_weight",
"global_steps",
"global_decay",
]
def test_cd_defaults_preserve_its_existing_square_layout() -> None:
"""Retain the default CD-only recipe while allowing independent local edits."""
(configured,) = ContextualDiffusionOptionsV3.execute()
assert configured.contextual_diffusion == ContextualDiffusionOptions()
assert configured.contextual_diffusion is not None
assert configured.contextual_diffusion.local_tiling() == TilingOptions(
width=96,
height=96,
overlap=32,
batch_size=4,
)
@pytest.mark.parametrize(
"controls",
[
{"diffusion_mode": "unknown"},
{"latent_context_overlap": -1},
{"latent_context_overlap": 96},
{"latent_context_batch_size": 0},
{"latent_context_size": 32, "latent_context_overlap": 32},
],
)
def test_cd_rejects_invalid_local_controls(controls: dict[str, Any]) -> None:
"""Fail before model execution when the chosen local plan is invalid."""
with pytest.raises(ValueError):
ContextualDiffusionOptionsV3.execute(**controls)
def test_cd_rejects_non_boolean_differential_diffusion() -> None:
"""Do not accept arbitrary workflow payloads for a mask-policy toggle."""
malformed: Any = 1
with pytest.raises(TypeError, match="boolean"):
ContextualDiffusionOptionsV3.execute(differential_diffusion=malformed)
@@ -10,6 +10,7 @@ from typing import Any, ClassVar
import torch
from simple_syrup.domain.noise_inversion import NoiseInversionOptions
from simple_syrup.nodes_v3.ksampler_attention_coupling import (
KSamplerAttentionCouplingV3,
)
@@ -49,13 +50,11 @@ def test_schema_exposes_stable_full_context_contract_and_guidance() -> None:
"region_mask_feather",
"latent_image",
"denoise",
"noise_inversion_enabled",
"inversion_method",
"inversion_resolution_scale",
"inversion_steps",
"inversion_switch_fraction",
"inversion_finishing_steps",
"inversion_finishing_method",
]
inputs = {item.id: item for item in schema.inputs}
assert inputs["negative"].optional is True
@@ -111,7 +110,7 @@ def test_node_delegates_the_complete_request_once() -> None:
assert output is _RecordingAttentionService.output
assert _RecordingAttentionService.calls == [
{
"noise_inversion": None,
"noise_inversion": NoiseInversionOptions(),
"model": "model",
"seed": 9,
"steps": 30,
@@ -12,6 +12,7 @@ import pytest
import torch
from simple_syrup.domain.context_segs import ContextSegmentSequence
from simple_syrup.domain.noise_inversion import NoiseInversionOptions
from simple_syrup.nodes_v3.ksampler_contextual_attention_coupling import (
KSamplerContextualAttentionCouplingV3,
)
@@ -73,13 +74,11 @@ def test_schema_exposes_stable_contextual_attention_coupling_contract() -> None:
"global_steps",
"global_decay",
"segs",
"noise_inversion_enabled",
"inversion_method",
"inversion_resolution_scale",
"inversion_steps",
"inversion_switch_fraction",
"inversion_finishing_steps",
"inversion_finishing_method",
]
assert [output.id for output in schema.outputs] == ["latent", "contexts_segs"]
assert inputs["segs"].optional
@@ -181,7 +180,7 @@ def test_node_delegates_every_contextual_attention_input_once(
"global_steps": 4,
"global_decay": 0.25,
"segs": segs,
"noise_inversion": None,
"noise_inversion": NoiseInversionOptions(),
}
]
@@ -11,6 +11,7 @@ from typing import Any
import pytest
import torch
from simple_syrup.domain.noise_inversion import NoiseInversionOptions
from simple_syrup.domain.segs import NativeSegs
from simple_syrup.nodes_v3.ksampler_contextual_diffusion import (
KSamplerContextualDiffusionV3,
@@ -153,7 +154,7 @@ def test_sample_delegates_every_control_to_service(
"region_masks": None,
"regional_prompt_weight": 0.5,
"region_mask_feather": 0,
"noise_inversion": None,
"noise_inversion": NoiseInversionOptions(),
}
]
@@ -0,0 +1,62 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Protect the Extras sampler's schema and inversion-free execution contract."""
from __future__ import annotations
from inspect import signature
from typing import Any
import pytest
from simple_syrup.nodes.ksampler_extras import KSamplerExtras
from simple_syrup.nodes_v3.legacy_node_wrappers import KSamplerExtrasV3
from simple_syrup.nodes_v3.legacy_workflow_input_order import (
KSAMPLER_EXTRAS_INPUT_ORDER,
)
from simple_syrup.services.ksampler_sampling_service import KSamplerSamplingService
def test_extras_schema_has_only_ordinary_sampling_inputs() -> None:
"""Keep persisted input order without adding inversion widgets."""
schema = KSamplerExtrasV3.define_schema()
assert schema.node_id == "SimpleSyrup.KSamplerExtras"
assert tuple(item.id for item in schema.inputs) == KSAMPLER_EXTRAS_INPUT_ORDER
negative = next(item for item in schema.inputs if item.id == "negative")
assert negative.optional
assert "noise_inversion" not in signature(KSamplerExtras.sample).parameters
def test_extras_v3_execution_does_not_select_inversion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Delegate ordinary sampling without injecting the default inversion recipe."""
received: dict[str, Any] = {}
latent: dict[str, Any] = {"samples": object()}
class RecordingSamplingService(KSamplerSamplingService):
"""Record the node-to-service boundary without neural execution."""
def sample(self, **kwargs: Any) -> dict[str, Any]:
"""Capture delegated inputs and return the original latent."""
received.update(kwargs)
return latent
monkeypatch.setattr(KSamplerExtras, "service_class", RecordingSamplingService)
result = KSamplerExtrasV3.execute(
model=object(),
seed=123,
steps=4,
cfg=1.0,
sampler_name="euler",
scheduler="simple",
positive=object(),
latent_image=latent,
)
assert result == (latent,)
assert received["latent_image"] is latent
assert received["negative"] is None
assert received["seed"] == 123
assert "noise_inversion" not in received
@@ -11,6 +11,7 @@ from typing import Any, ClassVar
import pytest
import torch
from simple_syrup.domain.noise_inversion import NoiseInversionOptions
from simple_syrup.nodes_v3.ksampler_tiled_attention_coupling import (
KSamplerTiledAttentionCouplingV3,
)
@@ -61,13 +62,11 @@ def test_schema_exposes_stable_tiled_attention_coupling_contract() -> None:
"latent_tile_height",
"latent_tile_overlap",
"latent_tile_batch_size",
"noise_inversion_enabled",
"inversion_method",
"inversion_resolution_scale",
"inversion_steps",
"inversion_switch_fraction",
"inversion_finishing_steps",
"inversion_finishing_method",
]
assert inputs["region_masks"].optional is True
assert inputs["regional_prompt_weight"].default == 1.0
@@ -141,7 +140,7 @@ def test_node_delegates_every_tiled_attention_input_once(
assert output is _RecordingTiledAttentionService.output
assert _RecordingTiledAttentionService.calls == [
{
"noise_inversion": None,
"noise_inversion": NoiseInversionOptions(),
"diffusion_mode": "mixture_of_diffusers",
"model": "model",
"seed": 17,
@@ -2,7 +2,7 @@
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Safeguard the accepted inversion recipe and independently editable controls."""
"""Safeguard the accepted inversion recipe and shared integration method."""
from __future__ import annotations
@@ -22,7 +22,6 @@ def test_defaults_match_accepted_half_euler_recipe() -> None:
assert options.steps == 2
assert options.switch_fraction == 0.75
assert options.finishing_steps == 1
assert options.finishing_method == "euler"
assert options.coarse_target_fraction == 0.75
@@ -30,7 +29,6 @@ def test_defaults_match_accepted_half_euler_recipe() -> None:
("changes", "message"),
[
({"method": "fireflow"}, "method"),
({"finishing_method": "invalid"}, "finishing method"),
({"resolution_scale": 0}, "resolution"),
({"resolution_scale": 1.1}, "resolution"),
({"resolution_scale": float("nan")}, "resolution"),
@@ -178,6 +178,27 @@ def test_coarse_only_inversion_reaches_forward_target(
assert float(guider_calls[0]["sigmas"][-1]) == 0.5
@pytest.mark.parametrize(
("method", "evaluations"), [("euler", [2, 1]), ("heun", [4, 2])]
)
def test_selected_method_applies_to_both_resolution_stages(
guider_calls: list[dict[str, Any]], method: Any, evaluations: list[int]
) -> None:
"""Use the shared integrator for coarse inversion and full-resolution finishing."""
result = noise_inversion.invert_sampling_noise(
model=_model(_flow()),
latent=torch.ones((1, 1, 8, 12)),
forward_sigmas=torch.tensor([0.5, 0]),
positive=[],
negative=None,
cfg=1,
seed=1,
options=NoiseInversionOptions(method=method),
)
assert len(guider_calls) == 2
assert [phase.evaluations for phase in result.stages] == evaluations
@pytest.mark.parametrize(
("sampling", "target"),
[(_flow(), 1), (_flow(), 0), (_epsilon(), 10), (_epsilon(), 11)],
@@ -0,0 +1,114 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Enforce automatic pack-wide sampler and scheduler menu availability."""
from __future__ import annotations
from typing import Any, Protocol, cast
import comfy.samplers
import pytest
from simple_syrup import nodes_v3
from simple_syrup.runtime import sampling_samplers, sampling_schedulers
class SamplingNode(Protocol):
"""Describe the host-facing schema boundary inspected by this contract."""
@classmethod
def define_schema(cls) -> Any:
"""Declare sampling controls without executing a workflow."""
def sampling_nodes() -> tuple[type[SamplingNode], ...]:
"""Discover every exported KSampler and detailer without a manual inventory."""
nodes = tuple(
cast(type[SamplingNode], node)
for node in nodes_v3.get_nodes()
if node.__name__.startswith(("KSampler", "Detail"))
)
assert nodes, "The pack must export sampling nodes."
return nodes
@pytest.mark.parametrize("prompt_control_available", [False, True])
def test_every_exported_sampling_node_uses_complete_catalogs(
monkeypatch: pytest.MonkeyPatch,
prompt_control_available: bool,
) -> None:
"""Keep all registered sampling menus aligned in both integration states."""
monkeypatch.setattr(
nodes_v3, "prompt_control_is_available", lambda: prompt_control_available
)
for node in sampling_nodes():
schema = node.define_schema()
inputs = {item.id: item for item in schema.inputs}
assert tuple(inputs["sampler_name"].options) == (
sampling_samplers.available_samplers()
), schema.node_id
assert tuple(inputs["scheduler"].options) == (
sampling_schedulers.available_schedulers()
), schema.node_id
@pytest.mark.parametrize("source", ["pack", "res4lyf", "host"])
def test_catalog_additions_propagate_without_node_edits(
monkeypatch: pytest.MonkeyPatch,
source: str,
) -> None:
"""Rebuild every menu from changed catalogs, preserving all other contracts."""
monkeypatch.setattr(nodes_v3, "prompt_control_is_available", lambda: False)
nodes = sampling_nodes()
original = {node: node.define_schema() for node in nodes}
sampler_name = "catalog_test_sampler"
scheduler_name = "catalog_test_scheduler"
if source == "host":
monkeypatch.setattr(
comfy.samplers.KSampler,
"SAMPLERS",
(*comfy.samplers.KSampler.SAMPLERS, sampler_name, sampler_name),
)
monkeypatch.setattr(
comfy.samplers.KSampler,
"SCHEDULERS",
(*comfy.samplers.KSampler.SCHEDULERS, scheduler_name, scheduler_name),
)
else:
catalog = "RES4LYF_SAMPLER_NAMES" if source == "res4lyf" else "EXTRA_SAMPLERS"
monkeypatch.setattr(
sampling_samplers,
catalog,
(*getattr(sampling_samplers, catalog), sampler_name, sampler_name),
)
monkeypatch.setattr(
sampling_schedulers,
"EXTRA_SCHEDULERS",
(*sampling_schedulers.EXTRA_SCHEDULERS, scheduler_name, scheduler_name),
)
for node in nodes:
schema = node.define_schema()
previous = original[node]
assert schema.node_id == previous.node_id
assert [item.as_dict() for item in schema.outputs] == [
item.as_dict() for item in previous.outputs
]
assert [item.id for item in schema.inputs] == [
item.id for item in previous.inputs
]
inputs = {item.id: item for item in schema.inputs}
assert tuple(inputs["sampler_name"].options) == (
sampling_samplers.available_samplers()
), schema.node_id
assert tuple(inputs["scheduler"].options) == (
sampling_schedulers.available_schedulers()
), schema.node_id
assert inputs["sampler_name"].options.count(sampler_name) == 1
assert inputs["scheduler"].options.count(scheduler_name) == 1
for current, prior in zip(schema.inputs, previous.inputs, strict=True):
assert current.optional == prior.optional
assert getattr(current, "default", None) == getattr(prior, "default", None)
+4 -4
View File
@@ -28,7 +28,7 @@ def test_all_distinct_capability_orders_produce_identical_configuration() -> Non
TilingOptions(),
ContextualDiffusionOptions(),
NoiseInversionOptions(),
AttentionCouplingOptions(region_masks=object()),
AttentionCouplingOptions(),
)
expected = SamplerOptions(
tiling=capabilities[0],
@@ -50,7 +50,7 @@ def test_all_distinct_capability_orders_produce_identical_configuration() -> Non
TilingOptions(),
ContextualDiffusionOptions(),
NoiseInversionOptions(),
AttentionCouplingOptions(region_masks=object()),
AttentionCouplingOptions(),
],
)
def test_every_feature_can_start_a_chain_and_rejects_duplicates(
@@ -84,10 +84,10 @@ def test_branched_options_do_not_mutate_the_shared_upstream_configuration() -> N
(ContextualDiffusionOptions, {"global_weight": float("nan")}),
(ContextualDiffusionOptions, {"global_decay": 2}),
(ContextualDiffusionOptions, {"global_steps": -1}),
(AttentionCouplingOptions, {"region_masks": None}),
(AttentionCouplingOptions, {"region_mask_feather": -1}),
(
AttentionCouplingOptions,
{"region_masks": object(), "regional_prompt_weight": 1.1},
{"regional_prompt_weight": 1.1},
),
],
)
@@ -0,0 +1,110 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Verify optional sampler masks admit only complete regional requests."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
import torch
from simple_syrup.domain.conditioning_batch import ConditioningBatch
from simple_syrup.domain.sampler_options import (
AttentionCouplingOptions,
ContextualDiffusionOptions,
SamplerOptions,
TilingOptions,
)
from simple_syrup.nodes_v3.ksampler import KSamplerV3
from simple_syrup.services import sampler_options_sampling_service as routing
@pytest.fixture(params=["full", "tiled", "context"])
def attention_options(request: pytest.FixtureRequest) -> SamplerOptions:
"""Configure attention independently from every spatial sampling authority."""
return SamplerOptions(
attention_coupling=AttentionCouplingOptions(),
tiling=TilingOptions() if request.param == "tiled" else None,
contextual_diffusion=(
ContextualDiffusionOptions() if request.param == "context" else None
),
)
def sampling_inputs() -> dict[str, Any]:
"""Describe a small host-facing latent without loading any neural model."""
return {
"model": object(),
"seed": 17,
"steps": 4,
"cfg": 3.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": [],
"negative": None,
"latent_image": {"samples": torch.zeros((1, 4, 64, 96))},
}
@pytest.mark.parametrize("missing", ["masks", "batch"])
def test_partial_attention_request_fails_before_model_execution(
attention_options: SamplerOptions,
missing: str,
) -> None:
"""Surface the established actionable errors through the actual sampler node."""
values = sampling_inputs()
if missing == "masks":
values["positive"] = ConditioningBatch(([], []))
message = "conditioning batches require region_masks"
else:
values["region_masks"] = torch.ones((1, 64, 96))
message = "region_masks require a CONDITIONING_BATCH"
with pytest.raises(ValueError, match=message):
KSamplerV3.execute(**values, options=attention_options)
def test_attention_options_without_regional_inputs_use_ordinary_sampling(
attention_options: SamplerOptions,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Bypass inactive attention while preserving the selected spatial strategy."""
calls: list[tuple[str, dict[str, Any]]] = []
def boundary(label: str) -> type:
"""Replace neural sampling after the real node and compiler admit inputs."""
class Recorder:
"""Capture the expensive sampling boundary without preparing a model."""
def sample(self, **kwargs: Any) -> Any:
"""Retain sampling payloads and the host's exact output shape."""
calls.append((label, kwargs))
if label == "context":
return SimpleNamespace(latent=kwargs["latent_image"])
return kwargs["latent_image"]
return Recorder
for name, label in (
("KSamplerSamplingService", "full"),
("TiledDiffusionSamplingService", "tiled"),
("ContextualDiffusionSamplingService", "context"),
):
monkeypatch.setattr(routing, name, boundary(label))
values = sampling_inputs()
assert KSamplerV3.execute(**values, options=attention_options) == (
values["latent_image"],
)
expected = (
"context"
if attention_options.contextual_diffusion is not None
else "tiled"
if attention_options.tiling is not None
else "full"
)
assert len(calls) == 1 and calls[0][0] == expected
assert "region_masks" not in calls[0][1]
+163 -13
View File
@@ -19,11 +19,28 @@ from simple_syrup.nodes_v3.contextual_diffusion_options import (
ContextualDiffusionOptionsV3,
)
from simple_syrup.nodes_v3.ksampler import KSamplerV3
from simple_syrup.nodes_v3.ksampler_attention_coupling import (
KSamplerAttentionCouplingV3,
)
from simple_syrup.nodes_v3.ksampler_contextual_attention_coupling import (
KSamplerContextualAttentionCouplingV3,
)
from simple_syrup.nodes_v3.ksampler_contextual_diffusion import (
KSamplerContextualDiffusionV3,
)
from simple_syrup.nodes_v3.ksampler_prompt_by_region import KSamplerPromptByRegionV3
from simple_syrup.nodes_v3.ksampler_prompt_by_tiled_region import (
KSamplerPromptByTiledRegionV3,
)
from simple_syrup.nodes_v3.ksampler_tiled_attention_coupling import (
KSamplerTiledAttentionCouplingV3,
)
from simple_syrup.nodes_v3.ksampler_tiled_diffusion import KSamplerTiledDiffusionV3
from simple_syrup.nodes_v3.noise_inversion_options import NoiseInversionOptionsV3
from simple_syrup.nodes_v3.sampler_options_schema import OPTIONS_TYPE
from simple_syrup.nodes_v3.sampler_options_schema import (
OPTIONS_TYPE,
inversion_from_controls,
)
from simple_syrup.nodes_v3.tiling_options import TilingOptionsV3
from simple_syrup.services.sampler_options_sampling_service import SamplingArguments
@@ -46,18 +63,17 @@ def test_each_capability_can_start_a_natively_bypassable_chain(node: Any) -> Non
assert "enabled" not in " ".join(value.id for value in schema.inputs)
assert schema.description
assert all(value.tooltip for value in (*schema.inputs, *schema.outputs))
assert "segs" not in {value.id for value in schema.inputs}
assert "region_masks" not in {value.id for value in schema.inputs}
def test_all_node_orders_preserve_configuration_and_branches() -> None:
"""Build all 24 real node chains without touching MODEL preparation."""
masks = torch.ones((1, 32, 64))
reference: SamplerOptions | None = None
for order in permutations(OPTION_NODES):
chain: SamplerOptions | None = None
for node in order:
kwargs: dict[str, Any] = {"options": chain}
if node is AttentionCouplingOptionsV3:
kwargs["region_masks"] = masks
previous = chain
(chain,) = node.execute(**kwargs)
assert chain is not previous
@@ -73,14 +89,9 @@ def test_all_node_orders_preserve_configuration_and_branches() -> None:
@pytest.mark.parametrize("node", OPTION_NODES)
def test_duplicate_capability_node_fails_explicitly(node: Any) -> None:
"""Reject ambiguous controls rather than choosing a winner by connection order."""
kwargs = (
{"region_masks": torch.ones((1, 4, 4))}
if node is AttentionCouplingOptionsV3
else {}
)
(first,) = node.execute(**kwargs)
(first,) = node.execute()
with pytest.raises(ValueError, match="Duplicate sampler capability"):
node.execute(options=first, **kwargs)
node.execute(options=first)
def test_inversion_node_defaults_match_convenience_controls(
@@ -115,20 +126,159 @@ def test_inversion_node_defaults_match_convenience_controls(
"latent_image": {"samples": torch.zeros((1, 4, 32, 64))},
"denoise": 0.5,
}
KSamplerContextualDiffusionV3.execute(**standard, noise_inversion_enabled=True)
KSamplerContextualDiffusionV3.execute(**standard)
assert (
calls[-1]["noise_inversion"]
== options.noise_inversion
== NoiseInversionOptions()
)
KSamplerContextualDiffusionV3.execute(**standard)
KSamplerContextualDiffusionV3.execute(**standard, inversion_steps=0)
assert calls[-1]["noise_inversion"] is None
assert calls[-1]["negative"] is None
def test_zero_inversion_steps_pass_through_existing_options() -> None:
"""Disable both inversion stages without discarding preceding capabilities."""
(upstream,) = TilingOptionsV3.execute()
(result,) = NoiseInversionOptionsV3.execute(options=upstream, inversion_steps=0)
assert result is upstream
assert result.noise_inversion is None
(empty,) = NoiseInversionOptionsV3.execute(inversion_steps=0)
assert empty == SamplerOptions()
assert (
inversion_from_controls(inversion_steps=0, inversion_finishing_steps=8) is None
)
@pytest.mark.parametrize("steps", [-1, 65, True, 1.5])
def test_inversion_step_disable_rejects_invalid_counts(steps: Any) -> None:
"""Reserve integer zero for disabling rather than accepting false-like inputs."""
with pytest.raises(ValueError, match="steps"):
inversion_from_controls(inversion_steps=steps)
def test_inversion_schema_has_one_method_and_zero_step_disable() -> None:
"""Keep the accepted default enabled through five unambiguous controls."""
inputs = {
value.id: value for value in NoiseInversionOptionsV3.define_schema().inputs
}
assert "noise_inversion_enabled" not in inputs
assert "inversion_finishing_method" not in inputs
assert inputs["inversion_steps"].default == 2
assert inputs["inversion_steps"].min == 0
@pytest.mark.parametrize(
"node",
[
KSamplerAttentionCouplingV3,
KSamplerContextualAttentionCouplingV3,
KSamplerContextualDiffusionV3,
KSamplerPromptByRegionV3,
KSamplerPromptByTiledRegionV3,
KSamplerTiledAttentionCouplingV3,
KSamplerTiledDiffusionV3,
],
)
@pytest.mark.parametrize("inversion_steps", [0, 2])
def test_every_native_sampler_uses_shared_default_and_disable(
node: Any, inversion_steps: int, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Exercise all sampler entry points with enabled defaults and explicit disable."""
from types import SimpleNamespace
calls: list[dict[str, Any]] = []
class RecordingService:
"""Observe orchestration without invoking external model execution."""
def sample(self, **kwargs: Any) -> Any:
"""Return either the contextual result or the normal latent shape."""
calls.append(kwargs)
if "latent_context_size" in kwargs:
return SimpleNamespace(latent=kwargs["latent_image"], contexts=object())
return kwargs["latent_image"]
def assemble(self, **kwargs: Any) -> tuple[object, object]:
"""Pass through regional conditioning at its external boundary."""
return kwargs["positive"], kwargs["negative"]
for name in (
"service_class",
"sampling_service_class",
"conditioning_service_class",
):
if hasattr(node, name):
monkeypatch.setattr(node, name, RecordingService)
controls: dict[str, Any] = {} if inversion_steps == 2 else {"inversion_steps": 0}
node.execute(
model=object(),
seed=1,
steps=4,
cfg=1.0,
sampler_name="euler",
scheduler="simple",
positive=[],
negative=None,
latent_image={"samples": torch.zeros((1, 4, 8, 8))},
region_masks=torch.ones((1, 8, 8)),
denoise=0.5,
**controls,
)
assert calls[-1]["noise_inversion"] == (
NoiseInversionOptions() if inversion_steps else None
)
inputs = {value.id: value for value in node.define_schema().inputs}
assert inputs["inversion_steps"].default == 2
assert inputs["inversion_steps"].min == 0
assert "noise_inversion_enabled" not in inputs
assert "inversion_finishing_method" not in inputs
def test_new_sampler_preserves_optional_negative_and_consumes_typed_options() -> None:
"""Keep standard Comfy sampling sockets with one optional capability connection."""
inputs = {value.id: value for value in KSamplerV3.define_schema().inputs}
assert inputs["negative"].optional
assert inputs["segs"].optional and inputs["segs"].io_type == "SEGS"
assert inputs["region_masks"].optional and inputs["region_masks"].io_type == "MASK"
assert inputs["options"].optional and inputs["options"].io_type == OPTIONS_TYPE
assert inputs["steps"].default == 20 and inputs["cfg"].default == 8.0
def test_sampler_forwards_region_data_without_mutating_options(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Keep region data on the execution boundary rather than the reusable chain."""
calls: list[dict[str, Any]] = []
class RecordingService:
"""Observe the sampler's public delegation without neural execution."""
def sample(self, **kwargs: Any) -> dict[str, Any]:
"""Return the exact latent payload after recording connected regions."""
calls.append(kwargs)
result: dict[str, Any] = kwargs["latent_image"]
return result
monkeypatch.setattr(KSamplerV3, "service_class", RecordingService)
(options,) = TilingOptionsV3.execute()
segs = object()
masks = torch.ones((1, 16, 16))
latent = {"samples": torch.zeros((1, 4, 16, 16))}
assert KSamplerV3.execute(
model=object(),
seed=17,
steps=4,
cfg=3.0,
sampler_name="euler",
scheduler="simple",
positive=[],
latent_image=latent,
options=options,
segs=segs,
region_masks=masks,
) == (latent,)
assert calls[0]["segs"] is segs
assert calls[0]["region_masks"] is masks
assert calls[0]["options"] is options
assert calls[0]["negative"] is None
@@ -14,6 +14,7 @@ import pytest
import torch
from comfy.model_sampling import CONST
from simple_syrup.domain.conditioning_batch import ConditioningBatch
from simple_syrup.domain.noise_inversion import NoiseInversionOptions
from simple_syrup.domain.sampler_options import (
AttentionCouplingOptions,
@@ -22,6 +23,10 @@ from simple_syrup.domain.sampler_options import (
SamplerOptions,
TilingOptions,
)
from simple_syrup.nodes_v3.contextual_diffusion_options import (
ContextualDiffusionOptionsV3,
)
from simple_syrup.nodes_v3.tiling_options import TilingOptionsV3
from simple_syrup.runtime import sampling_schedulers
from simple_syrup.services import sampler_options_sampling_service as routing
@@ -86,16 +91,22 @@ def capabilities() -> tuple[SamplerCapability, ...]:
"""Keep local geometry independent from the reduced whole-scene context."""
return (
TilingOptions(width=64, height=48, overlap=8, batch_size=2),
ContextualDiffusionOptions(context_size=32, global_weight=0.8, global_steps=3),
ContextualDiffusionOptions(
context_size=32, overlap=8, global_weight=0.8, global_steps=3
),
NoiseInversionOptions(),
AttentionCouplingOptions(torch.ones((1, 64, 96)), 0.7, 2),
AttentionCouplingOptions(0.7, 2),
)
@pytest.mark.parametrize("enabled", tuple(product((False, True), repeat=4)))
@pytest.mark.parametrize("connected_segs", [None, object()])
@pytest.mark.parametrize("connected_masks", [None, object()])
def test_every_capability_combination_uses_one_sampling_authority(
routed_calls: list[tuple[str, dict[str, Any]]],
enabled: tuple[bool, bool, bool, bool],
connected_segs: object | None,
connected_masks: object | None,
) -> None:
"""Compose all 16 combinations without dropping configuration or negatives."""
options = SamplerOptions()
@@ -103,10 +114,15 @@ def test_every_capability_combination_uses_one_sampling_authority(
if active:
options = options.with_capability(capability)
values = arguments()
output = routing.SamplerOptionsSamplingService().sample(**values, options=options)
if enabled[3] and connected_masks is not None:
values["positive"] = ConditioningBatch(([], []))
output = routing.SamplerOptionsSamplingService().sample(
**values, options=options, segs=connected_segs, region_masks=connected_masks
)
assert output is values["latent_image"]
assert len(routed_calls) == 1
tile, context, inversion, attention = enabled
attention = attention and connected_masks is not None
expected = (
("context_attention" if attention else "context")
if context
@@ -118,6 +134,10 @@ def test_every_capability_combination_uses_one_sampling_authority(
)
label, forwarded = routed_calls[0]
assert label == expected
if tile or context:
assert forwarded["segs"] is connected_segs
else:
assert "segs" not in forwarded
assert forwarded["negative"] is None
assert forwarded["noise_inversion"] == (
NoiseInversionOptions() if inversion else None
@@ -125,14 +145,17 @@ def test_every_capability_combination_uses_one_sampling_authority(
assert forwarded["model"] is values["model"]
if context:
assert forwarded["latent_context_size"] == 32
assert forwarded["tiling"].width == (64 if tile else 32)
assert forwarded["tiling"].height == (48 if tile else 32)
assert forwarded["tiling"].width == 32
assert forwarded["tiling"].height == 32
elif tile:
assert forwarded["latent_tile_width"] == 64
assert forwarded["latent_tile_height"] == 48
if attention:
assert forwarded["region_masks"] is connected_masks
assert forwarded["regional_prompt_weight"] == 0.7
assert forwarded["region_mask_feather"] == 2
else:
assert "region_masks" not in forwarded
def test_all_connection_orders_compile_identical_execution(
@@ -140,12 +163,14 @@ def test_all_connection_orders_compile_identical_execution(
) -> None:
"""Require all 24 capability orders to reach the same service with exact values."""
values = arguments()
values["positive"] = ConditioningBatch(([], []))
masks = torch.ones((1, 64, 96))
for order in permutations(capabilities()):
options = SamplerOptions()
for capability in order:
options = options.with_capability(capability)
output = routing.SamplerOptionsSamplingService().sample(
**values, options=options
**values, options=options, region_masks=masks
)
assert output is values["latent_image"]
assert routed_calls[-1] == routed_calls[0]
@@ -198,3 +223,113 @@ def test_unipc_conflicts_fail_before_sampling(
with pytest.raises(ValueError, match="UniPC"):
routing.SamplerOptionsSamplingService().sample(**values, options=options)
assert not routed_calls
@pytest.mark.parametrize("cd_first", [False, True])
@pytest.mark.parametrize("attention", [False, True])
def test_cd_owns_all_local_controls_and_ignores_tiling_in_either_order(
routed_calls: list[tuple[str, dict[str, Any]]],
caplog: pytest.LogCaptureFixture,
cd_first: bool,
attention: bool,
) -> None:
"""Prove conflicting tiling cannot leak into CD routing, including its preflight."""
cd_segs = object()
(cd,) = ContextualDiffusionOptionsV3.execute(
diffusion_mode="mixture_of_diffusers",
latent_context_size=48,
latent_context_overlap=12,
latent_context_batch_size=2,
differential_diffusion=True,
)
base = cd
if attention:
base = base.with_capability(AttentionCouplingOptions())
values = arguments()
masks = torch.ones((1, 64, 96)) if attention else None
if attention:
values["positive"] = ConditioningBatch(([], []))
routing.SamplerOptionsSamplingService().sample(
**values, options=base, segs=cd_segs, region_masks=masks
)
reference = routed_calls[-1]
assert not caplog.records
def tiling_node(options: SamplerOptions | None = None) -> SamplerOptions:
"""Build a conflicting local configuration at the actual node boundary."""
(result,) = TilingOptionsV3.execute(
options=options,
diffusion_mode="multidiffusion",
latent_tile_width=128,
latent_tile_height=64,
latent_tile_overlap=32,
latent_tile_batch_size=4,
differential_diffusion=False,
)
return result
if cd_first:
combined = tiling_node(base)
else:
tiles = tiling_node()
assert base.contextual_diffusion is not None
combined = tiles.with_capability(base.contextual_diffusion)
if attention:
assert base.attention_coupling is not None
combined = combined.with_capability(base.attention_coupling)
routing.SamplerOptionsSamplingService().sample(
**values, options=combined, segs=cd_segs, region_masks=masks
)
assert routed_calls[-1] == reference
forwarded = reference[1]
assert forwarded["tiling"] == TilingOptions(
diffusion_mode="mixture_of_diffusers",
width=48,
height=48,
overlap=12,
batch_size=2,
differential_diffusion=True,
)
assert forwarded["segs"] is cd_segs
assert forwarded["latent_context_overlap"] == 12
assert forwarded["latent_context_batch_size"] == 2
assert len(caplog.records) == 1
assert caplog.records[0].levelname == "WARNING"
assert caplog.records[0].getMessage() == (
"Contextual Diffusion takes precedence; Tiling Options ignored."
)
assert caplog.records[0].__dict__["node_id"] == "SimpleSyrup.KSampler"
# Bypassing CD leaves the original tiling capability intact.
assert combined.tiling is not None
routing.SamplerOptionsSamplingService().sample(
**values, options=SamplerOptions(tiling=combined.tiling), segs=cd_segs
)
assert routed_calls[-1][0] == "tiled"
assert routed_calls[-1][1]["latent_tile_width"] == 128
assert routed_calls[-1][1]["segs"] is cd_segs
assert len(caplog.records) == 1
def test_inversion_preflight_uses_cd_geometry_not_ignored_tiles(
routed_calls: list[tuple[str, dict[str, Any]]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Use the effective CD view when validating geometry-dependent inversion sigmas."""
views: list[sampling_schedulers.SchedulerView] = []
def sigmas(**kwargs: Any) -> torch.Tensor:
"""Observe the external scheduler boundary without running model inference."""
views.append(kwargs["view"])
return torch.tensor([0.5, 0.25, 0.0])
monkeypatch.setattr(sampling_schedulers, "calculate_sigmas", sigmas)
routing.SamplerOptionsSamplingService().sample(
**arguments(),
options=SamplerOptions(
tiling=TilingOptions(width=128, height=64),
contextual_diffusion=ContextualDiffusionOptions(context_size=48, overlap=8),
noise_inversion=NoiseInversionOptions(),
),
)
assert views == [sampling_schedulers.SchedulerView(48, 48)]
assert len(routed_calls) == 1
@@ -77,13 +77,11 @@ class ProfiledKSamplerAttentionCouplingV3(_ComfyNodeBase):
region_masks: object | None = None,
regional_prompt_weight: float = 1.0,
region_mask_feather: int = 0,
noise_inversion_enabled: bool = False,
inversion_method: str = "euler",
inversion_resolution_scale: float = 0.5,
inversion_steps: int = 2,
inversion_switch_fraction: float = 0.75,
inversion_finishing_steps: int = 1,
inversion_finishing_method: str = "euler",
) -> tuple[dict[str, Any]]:
"""Bridge canonical host batches, then run the inherited exact delegate."""
@@ -105,11 +103,9 @@ class ProfiledKSamplerAttentionCouplingV3(_ComfyNodeBase):
region_mask_feather=region_mask_feather,
latent_image=latent_image,
denoise=denoise,
noise_inversion_enabled=noise_inversion_enabled,
inversion_method=inversion_method,
inversion_resolution_scale=inversion_resolution_scale,
inversion_steps=inversion_steps,
inversion_switch_fraction=inversion_switch_fraction,
inversion_finishing_steps=inversion_finishing_steps,
inversion_finishing_method=inversion_finishing_method,
)
+44
View File
@@ -3072,6 +3072,49 @@ function isPreviewNode(value) {
return node.constructor?.comfyClass === SIMPLE_PREVIEW_SEGS_NODE_ID && typeof node.addDOMWidget === "function";
}
// web/src/samplerSocketOrder.ts
var SOCKET_ORDER = [
"model",
"positive",
"negative",
"latent_image",
"options",
"segs",
"region_masks"
];
function registerSamplerSocketOrder(app2) {
app2.registerExtension({
name: "SimpleSyrup.SamplerSocketOrder",
nodeCreated(candidate) {
if (!isSampler(candidate)) return;
orderSockets(candidate);
const configured = candidate.onGraphConfigured;
candidate.onGraphConfigured = function(...args) {
const result = configured?.apply(this, args);
orderSockets(this);
return result;
};
}
});
}
function orderSockets(node) {
const ranks = new Map(SOCKET_ORDER.map((name, index) => [name, index]));
node.inputs.sort(
(left, right) => (ranks.get(left.name) ?? SOCKET_ORDER.length) - (ranks.get(right.name) ?? SOCKET_ORDER.length)
);
for (const [index, input] of node.inputs.entries()) {
if (input.link == null) continue;
const link = node.graph?.links[input.link];
if (link) link.target_slot = index;
}
node.graph?.setDirtyCanvas?.(true, true);
}
function isSampler(value) {
if (typeof value !== "object" || value === null) return false;
const node = value;
return node.constructor?.comfyClass === "SimpleSyrup.KSampler" && Array.isArray(node.inputs) && node.inputs.every((input) => typeof input.name === "string");
}
// web/src/main.ts
var comfyApp = app;
var comfyApi = window.comfyAPI.api.api;
@@ -3085,3 +3128,4 @@ comfyApp.registerExtension({
registerMaskBatchUpload(comfyApp, comfyApi);
registerImageListUpload(comfyApp, comfyApi);
registerSimplePreviewSEGS(comfyApp, comfyApi);
registerSamplerSocketOrder(comfyApp);
+2
View File
@@ -10,6 +10,7 @@ import { registerExternalLLMRefreshHook } from "./refresh";
import { registerMaskBatchUpload } from "./maskBatchUpload";
import { registerImageListUpload } from "./imageListUpload";
import { registerSimplePreviewSEGS } from "./segPreviewNode";
import { registerSamplerSocketOrder } from "./samplerSocketOrder";
import type { ComfyApi, ComfyApp } from "./types";
interface ComfyRuntimeWindow extends Window {
@@ -31,3 +32,4 @@ comfyApp.registerExtension({
registerMaskBatchUpload(comfyApp, comfyApi);
registerImageListUpload(comfyApp, comfyApi);
registerSimplePreviewSEGS(comfyApp, comfyApi);
registerSamplerSocketOrder(comfyApp);
+65
View File
@@ -0,0 +1,65 @@
// 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 { ComfyApp } from "./types";
interface SamplerSocket {
name: string;
link?: string | number | null;
}
interface SamplerInputNode {
constructor: { comfyClass?: string };
inputs: SamplerSocket[];
graph?: {
links: Record<string | number, { target_slot: number } | undefined>;
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
};
onGraphConfigured?: (...args: unknown[]) => unknown;
}
const SOCKET_ORDER = [
"model", "positive", "negative", "latent_image", "options", "segs", "region_masks"
];
/** Keep optional negatives beside positives without changing backend validation. */
export function registerSamplerSocketOrder(app: ComfyApp): void {
app.registerExtension({
name: "SimpleSyrup.SamplerSocketOrder",
nodeCreated(candidate: unknown) {
if (!isSampler(candidate)) return;
orderSockets(candidate);
const configured = candidate.onGraphConfigured;
candidate.onGraphConfigured = function (...args: unknown[]): unknown {
const result = configured?.apply(this, args);
orderSockets(this);
return result;
};
}
});
}
/** Preserve socket objects and repair saved links after the host restores a graph. */
function orderSockets(node: SamplerInputNode): void {
const ranks = new Map(SOCKET_ORDER.map((name, index) => [name, index]));
node.inputs.sort((left, right) =>
(ranks.get(left.name) ?? SOCKET_ORDER.length) -
(ranks.get(right.name) ?? SOCKET_ORDER.length)
);
for (const [index, input] of node.inputs.entries()) {
if (input.link == null) continue;
const link = node.graph?.links[input.link];
if (link) link.target_slot = index;
}
node.graph?.setDirtyCanvas?.(true, true);
}
/** Narrow the host boundary to the one sampler whose socket order we own. */
function isSampler(value: unknown): value is SamplerInputNode {
if (typeof value !== "object" || value === null) return false;
const node = value as Partial<SamplerInputNode>;
return node.constructor?.comfyClass === "SimpleSyrup.KSampler" &&
Array.isArray(node.inputs) &&
node.inputs.every((input) => typeof input.name === "string");
}
@@ -0,0 +1,126 @@
// 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 { registerSamplerSocketOrder } from "../../src/samplerSocketOrder";
import { createFakeComfyApp } from "../support/testUtils";
const correctOrder = [
"model", "positive", "negative", "latent_image", "options", "segs", "region_masks"
];
/** Represent the host's required-first socket layout and saved connection slots. */
function fakeSampler() {
return {
id: 10,
constructor: { comfyClass: "SimpleSyrup.KSampler" },
inputs: [
{ name: "model", link: 1 },
{ name: "positive", link: 2 },
{ name: "latent_image", link: 3 },
{ name: "negative", link: 4 },
{ name: "options", link: null },
{ name: "segs", link: 5 },
{ name: "region_masks", link: 6 }
],
widgets: [{ name: "seed", value: 17 }, { name: "denoise", value: 0.5 }],
graph: {
links: {
1: { target_id: 10, target_slot: 0 },
2: { target_id: 10, target_slot: 1 },
3: { target_id: 10, target_slot: 2 },
4: { target_id: 10, target_slot: 3 },
5: { target_id: 10, target_slot: 5 },
6: { target_id: 10, target_slot: 6 }
},
setDirtyCanvas: vi.fn()
},
onGraphConfigured: vi.fn(() => "preserved-result")
};
}
describe("sampler socket ordering", () => {
it("orders a new sampler without changing its inputs or widget values", async () => {
const app = createFakeComfyApp();
registerSamplerSocketOrder(app);
const node = fakeSampler();
const originalInputs = [...node.inputs];
const originalArray = node.inputs;
const widgets = [...node.widgets];
await app.extensions[0]?.nodeCreated?.(node);
expect(node.inputs.map((input) => input.name)).toEqual(correctOrder);
expect(node.inputs).toBe(originalArray);
expect(node.inputs).toEqual([
originalInputs[0], originalInputs[1], originalInputs[3],
originalInputs[2], originalInputs[4], originalInputs[5], originalInputs[6]
]);
expect(node.widgets).toEqual(widgets);
expect(node.graph.links[3].target_slot).toBe(3);
expect(node.graph.links[4].target_slot).toBe(2);
expect(node.inputs[5]?.link).toBe(5);
expect(node.graph.links[5].target_slot).toBe(5);
expect(node.inputs[6]?.link).toBe(6);
expect(node.graph.links[6].target_slot).toBe(6);
});
it("repairs old workflow link indices by socket name after configuration", async () => {
const app = createFakeComfyApp();
registerSamplerSocketOrder(app);
const node = fakeSampler();
const originalCallback = node.onGraphConfigured;
await app.extensions[0]?.nodeCreated?.(node);
// Comfy restores the old saved sockets and link indices when loading a workflow.
const restored = fakeSampler().inputs;
node.inputs = restored;
node.graph.links[3].target_slot = 2;
node.graph.links[4].target_slot = 3;
expect(node.onGraphConfigured()).toBe("preserved-result");
expect(originalCallback).toHaveBeenCalledOnce();
expect(node.inputs.map((input) => input.name)).toEqual(correctOrder);
expect(node.inputs[2]?.link).toBe(4);
expect(node.inputs[3]?.link).toBe(3);
expect(node.graph.links[3].target_slot).toBe(3);
expect(node.graph.links[4].target_slot).toBe(2);
// Already ordered serialized sockets still need their graph link slots repaired.
node.graph.links[3].target_slot = 2;
node.onGraphConfigured();
expect(node.graph.links[3].target_slot).toBe(3);
});
it("preserves added widget sockets and works without links or an existing callback", async () => {
const app = createFakeComfyApp();
registerSamplerSocketOrder(app);
const node = {
constructor: { comfyClass: "SimpleSyrup.KSampler" },
inputs: [
{ name: "model", link: null }, { name: "seed", link: null },
{ name: "positive", link: null }, { name: "latent_image", link: null },
{ name: "negative", link: null }, { name: "options", link: null },
{ name: "segs", link: null },
{ name: "region_masks", link: null },
{ name: "denoise", link: null }
],
onGraphConfigured: undefined as (() => unknown) | undefined
};
await app.extensions[0]?.nodeCreated?.(node);
expect(node.inputs.map((input) => input.name)).toEqual([
...correctOrder, "seed", "denoise"
]);
node.onGraphConfigured?.();
});
it.each<{ node: unknown }>([
{ node: null }, { node: {} },
{ node: { constructor: { comfyClass: "Other.KSampler" }, inputs: [] } }
])(
"does not alter unrelated or malformed nodes: $node", async ({ node }) => {
const app = createFakeComfyApp();
registerSamplerSocketOrder(app);
const snapshot = JSON.stringify(node);
await app.extensions[0]?.nodeCreated?.(node);
expect(JSON.stringify(node)).toBe(snapshot);
}
);
});