feat(sampling): refine sampler options and inversion controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Vendored
+44
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user