From ab7cebcebc4d5dfa95aa8d834456af1778fe56a6 Mon Sep 17 00:00:00 2001 From: Artificial Sweetener Date: Fri, 2 Oct 2026 12:51:27 -0400 Subject: [PATCH] feat(sampling): refine sampler options and inversion controls --- simple_syrup/domain/noise_inversion.py | 3 - simple_syrup/domain/sampler_options.py | 41 ++-- simple_syrup/nodes/ksampler_extras.py | 3 - .../nodes_v3/attention_coupling_options.py | 9 +- .../nodes_v3/contextual_diffusion_options.py | 48 +++-- simple_syrup/nodes_v3/ksampler.py | 23 ++- .../nodes_v3/ksampler_attention_coupling.py | 4 - .../ksampler_contextual_attention_coupling.py | 4 - .../nodes_v3/ksampler_contextual_diffusion.py | 4 - .../nodes_v3/ksampler_prompt_by_region.py | 4 - .../ksampler_prompt_by_tiled_region.py | 4 - .../ksampler_tiled_attention_coupling.py | 4 - .../nodes_v3/ksampler_tiled_diffusion.py | 4 - .../nodes_v3/legacy_inversion_node_adapter.py | 15 +- simple_syrup/nodes_v3/legacy_node_wrappers.py | 2 +- .../nodes_v3/noise_inversion_options.py | 6 +- .../nodes_v3/sampler_options_schema.py | 47 +---- simple_syrup/nodes_v3/tiling_options.py | 16 +- simple_syrup/runtime/noise_inversion.py | 2 +- .../sampler_options_sampling_service.py | 46 +++-- .../test_legacy_inversion_node_adapter.py | 31 ++- .../test_regional_ksampler_v3_nodes.py | 4 - .../test_contextual_diffusion_options_node.py | 91 +++++++++ ...est_ksampler_attention_coupling_v3_node.py | 5 +- ...r_contextual_attention_coupling_v3_node.py | 5 +- ...test_ksampler_contextual_diffusion_node.py | 3 +- .../sampling/test_ksampler_extras_v3_node.py | 62 ++++++ ...ampler_tiled_attention_coupling_v3_node.py | 5 +- .../sampling/test_noise_inversion_options.py | 4 +- .../sampling/test_noise_inversion_runtime.py | 21 +++ .../test_pack_sampling_catalog_contract.py | 114 ++++++++++++ tests/sampling/test_sampler_options.py | 8 +- .../test_sampler_options_attention_inputs.py | 110 +++++++++++ tests/sampling/test_sampler_options_nodes.py | 176 ++++++++++++++++-- .../test_sampler_options_sampling_service.py | 147 ++++++++++++++- .../attention_coupling_phase_node.py | 4 - web/dist/simple-syrup.js | 44 +++++ web/src/main.ts | 2 + web/src/samplerSocketOrder.ts | 65 +++++++ web/tests/sampling/samplerSocketOrder.test.ts | 126 +++++++++++++ 40 files changed, 1095 insertions(+), 221 deletions(-) create mode 100644 tests/sampling/test_contextual_diffusion_options_node.py create mode 100644 tests/sampling/test_ksampler_extras_v3_node.py create mode 100644 tests/sampling/test_pack_sampling_catalog_contract.py create mode 100644 tests/sampling/test_sampler_options_attention_inputs.py create mode 100644 web/src/samplerSocketOrder.ts create mode 100644 web/tests/sampling/samplerSocketOrder.test.ts diff --git a/simple_syrup/domain/noise_inversion.py b/simple_syrup/domain/noise_inversion.py index c413791..1508155 100644 --- a/simple_syrup/domain/noise_inversion.py +++ b/simple_syrup/domain/noise_inversion.py @@ -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 diff --git a/simple_syrup/domain/sampler_options.py b/simple_syrup/domain/sampler_options.py index d4d80b2..18629a8 100644 --- a/simple_syrup/domain/sampler_options.py +++ b/simple_syrup/domain/sampler_options.py @@ -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) diff --git a/simple_syrup/nodes/ksampler_extras.py b/simple_syrup/nodes/ksampler_extras.py index e436722..ebc8019 100644 --- a/simple_syrup/nodes/ksampler_extras.py +++ b/simple_syrup/nodes/ksampler_extras.py @@ -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,) diff --git a/simple_syrup/nodes_v3/attention_coupling_options.py b/simple_syrup/nodes_v3/attention_coupling_options.py index 93a43a5..1db8838 100644 --- a/simple_syrup/nodes_v3/attention_coupling_options.py +++ b/simple_syrup/nodes_v3/attention_coupling_options.py @@ -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, ), diff --git a/simple_syrup/nodes_v3/contextual_diffusion_options.py b/simple_syrup/nodes_v3/contextual_diffusion_options.py index 3b2daf7..04d85a2 100644 --- a/simple_syrup/nodes_v3/contextual_diffusion_options.py +++ b/simple_syrup/nodes_v3/contextual_diffusion_options.py @@ -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, ), ), ) diff --git a/simple_syrup/nodes_v3/ksampler.py b/simple_syrup/nodes_v3/ksampler.py index 1c638fb..3890d53 100644 --- a/simple_syrup/nodes_v3/ksampler.py +++ b/simple_syrup/nodes_v3/ksampler.py @@ -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, ), ) diff --git a/simple_syrup/nodes_v3/ksampler_attention_coupling.py b/simple_syrup/nodes_v3/ksampler_attention_coupling.py index dcf667e..89cc9d8 100644 --- a/simple_syrup/nodes_v3/ksampler_attention_coupling.py +++ b/simple_syrup/nodes_v3/ksampler_attention_coupling.py @@ -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, diff --git a/simple_syrup/nodes_v3/ksampler_contextual_attention_coupling.py b/simple_syrup/nodes_v3/ksampler_contextual_attention_coupling.py index 9228129..d8ede4a 100644 --- a/simple_syrup/nodes_v3/ksampler_contextual_attention_coupling.py +++ b/simple_syrup/nodes_v3/ksampler_contextual_attention_coupling.py @@ -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 diff --git a/simple_syrup/nodes_v3/ksampler_contextual_diffusion.py b/simple_syrup/nodes_v3/ksampler_contextual_diffusion.py index 3e86c98..2e51bf2 100644 --- a/simple_syrup/nodes_v3/ksampler_contextual_diffusion.py +++ b/simple_syrup/nodes_v3/ksampler_contextual_diffusion.py @@ -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 diff --git a/simple_syrup/nodes_v3/ksampler_prompt_by_region.py b/simple_syrup/nodes_v3/ksampler_prompt_by_region.py index 53ec77e..609a739 100644 --- a/simple_syrup/nodes_v3/ksampler_prompt_by_region.py +++ b/simple_syrup/nodes_v3/ksampler_prompt_by_region.py @@ -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, diff --git a/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py b/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py index 54b22fc..79bc483 100644 --- a/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py +++ b/simple_syrup/nodes_v3/ksampler_prompt_by_tiled_region.py @@ -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, diff --git a/simple_syrup/nodes_v3/ksampler_tiled_attention_coupling.py b/simple_syrup/nodes_v3/ksampler_tiled_attention_coupling.py index 6be605d..dffe7aa 100644 --- a/simple_syrup/nodes_v3/ksampler_tiled_attention_coupling.py +++ b/simple_syrup/nodes_v3/ksampler_tiled_attention_coupling.py @@ -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, diff --git a/simple_syrup/nodes_v3/ksampler_tiled_diffusion.py b/simple_syrup/nodes_v3/ksampler_tiled_diffusion.py index fbf546c..57a95c5 100644 --- a/simple_syrup/nodes_v3/ksampler_tiled_diffusion.py +++ b/simple_syrup/nodes_v3/ksampler_tiled_diffusion.py @@ -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,) diff --git a/simple_syrup/nodes_v3/legacy_inversion_node_adapter.py b/simple_syrup/nodes_v3/legacy_inversion_node_adapter.py index 8e6a5eb..6661a1c 100644 --- a/simple_syrup/nodes_v3/legacy_inversion_node_adapter.py +++ b/simple_syrup/nodes_v3/legacy_inversion_node_adapter.py @@ -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) diff --git a/simple_syrup/nodes_v3/legacy_node_wrappers.py b/simple_syrup/nodes_v3/legacy_node_wrappers.py index b9d53b4..28798a9 100644 --- a/simple_syrup/nodes_v3/legacy_node_wrappers.py +++ b/simple_syrup/nodes_v3/legacy_node_wrappers.py @@ -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 diff --git a/simple_syrup/nodes_v3/noise_inversion_options.py b/simple_syrup/nodes_v3/noise_inversion_options.py index 7498fd1..7d1db10 100644 --- a/simple_syrup/nodes_v3/noise_inversion_options.py +++ b/simple_syrup/nodes_v3/noise_inversion_options.py @@ -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),) diff --git a/simple_syrup/nodes_v3/sampler_options_schema.py b/simple_syrup/nodes_v3/sampler_options_schema.py index c48b078..7e67e69 100644 --- a/simple_syrup/nodes_v3/sampler_options_schema.py +++ b/simple_syrup/nodes_v3/sampler_options_schema.py @@ -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), ) diff --git a/simple_syrup/nodes_v3/tiling_options.py b/simple_syrup/nodes_v3/tiling_options.py index 236a825..4684cbe 100644 --- a/simple_syrup/nodes_v3/tiling_options.py +++ b/simple_syrup/nodes_v3/tiling_options.py @@ -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, ), ), ) diff --git a/simple_syrup/runtime/noise_inversion.py b/simple_syrup/runtime/noise_inversion.py index 766d671..a600de5 100644 --- a/simple_syrup/runtime/noise_inversion.py +++ b/simple_syrup/runtime/noise_inversion.py @@ -266,7 +266,7 @@ def invert_sampling_noise( options.finishing_steps, endpoint, "full_finish", - options.finishing_method, + options.method, ) sigma = torch.tensor(target) diff --git a/simple_syrup/services/sampler_options_sampling_service.py b/simple_syrup/services/sampler_options_sampling_service.py index 46b448d..552c38a 100644 --- a/simple_syrup/services/sampler_options_sampling_service.py +++ b/simple_syrup/services/sampler_options_sampling_service.py @@ -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, diff --git a/tests/node_api/test_legacy_inversion_node_adapter.py b/tests/node_api/test_legacy_inversion_node_adapter.py index 2cba9a2..c37d158 100644 --- a/tests/node_api/test_legacy_inversion_node_adapter.py +++ b/tests/node_api/test_legacy_inversion_node_adapter.py @@ -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 diff --git a/tests/regional_generation/regional/test_regional_ksampler_v3_nodes.py b/tests/regional_generation/regional/test_regional_ksampler_v3_nodes.py index ed345b7..7bcd847 100644 --- a/tests/regional_generation/regional/test_regional_ksampler_v3_nodes.py +++ b/tests/regional_generation/regional/test_regional_ksampler_v3_nodes.py @@ -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") diff --git a/tests/sampling/test_contextual_diffusion_options_node.py b/tests/sampling/test_contextual_diffusion_options_node.py new file mode 100644 index 0000000..ae43575 --- /dev/null +++ b/tests/sampling/test_contextual_diffusion_options_node.py @@ -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) diff --git a/tests/sampling/test_ksampler_attention_coupling_v3_node.py b/tests/sampling/test_ksampler_attention_coupling_v3_node.py index 03c2f1c..b1295fd 100644 --- a/tests/sampling/test_ksampler_attention_coupling_v3_node.py +++ b/tests/sampling/test_ksampler_attention_coupling_v3_node.py @@ -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, diff --git a/tests/sampling/test_ksampler_contextual_attention_coupling_v3_node.py b/tests/sampling/test_ksampler_contextual_attention_coupling_v3_node.py index 83f3603..db01a6c 100644 --- a/tests/sampling/test_ksampler_contextual_attention_coupling_v3_node.py +++ b/tests/sampling/test_ksampler_contextual_attention_coupling_v3_node.py @@ -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(), } ] diff --git a/tests/sampling/test_ksampler_contextual_diffusion_node.py b/tests/sampling/test_ksampler_contextual_diffusion_node.py index 422df41..187f777 100644 --- a/tests/sampling/test_ksampler_contextual_diffusion_node.py +++ b/tests/sampling/test_ksampler_contextual_diffusion_node.py @@ -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(), } ] diff --git a/tests/sampling/test_ksampler_extras_v3_node.py b/tests/sampling/test_ksampler_extras_v3_node.py new file mode 100644 index 0000000..fbb006a --- /dev/null +++ b/tests/sampling/test_ksampler_extras_v3_node.py @@ -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 diff --git a/tests/sampling/test_ksampler_tiled_attention_coupling_v3_node.py b/tests/sampling/test_ksampler_tiled_attention_coupling_v3_node.py index 8385e45..d9afd2c 100644 --- a/tests/sampling/test_ksampler_tiled_attention_coupling_v3_node.py +++ b/tests/sampling/test_ksampler_tiled_attention_coupling_v3_node.py @@ -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, diff --git a/tests/sampling/test_noise_inversion_options.py b/tests/sampling/test_noise_inversion_options.py index ff6ff8e..0a10534 100644 --- a/tests/sampling/test_noise_inversion_options.py +++ b/tests/sampling/test_noise_inversion_options.py @@ -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"), diff --git a/tests/sampling/test_noise_inversion_runtime.py b/tests/sampling/test_noise_inversion_runtime.py index 4607745..05f63d4 100644 --- a/tests/sampling/test_noise_inversion_runtime.py +++ b/tests/sampling/test_noise_inversion_runtime.py @@ -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)], diff --git a/tests/sampling/test_pack_sampling_catalog_contract.py b/tests/sampling/test_pack_sampling_catalog_contract.py new file mode 100644 index 0000000..1e7d6d3 --- /dev/null +++ b/tests/sampling/test_pack_sampling_catalog_contract.py @@ -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) diff --git a/tests/sampling/test_sampler_options.py b/tests/sampling/test_sampler_options.py index 30534eb..3ca6b8e 100644 --- a/tests/sampling/test_sampler_options.py +++ b/tests/sampling/test_sampler_options.py @@ -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}, ), ], ) diff --git a/tests/sampling/test_sampler_options_attention_inputs.py b/tests/sampling/test_sampler_options_attention_inputs.py new file mode 100644 index 0000000..3f0e249 --- /dev/null +++ b/tests/sampling/test_sampler_options_attention_inputs.py @@ -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] diff --git a/tests/sampling/test_sampler_options_nodes.py b/tests/sampling/test_sampler_options_nodes.py index ab1efde..58d82ee 100644 --- a/tests/sampling/test_sampler_options_nodes.py +++ b/tests/sampling/test_sampler_options_nodes.py @@ -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 diff --git a/tests/sampling/test_sampler_options_sampling_service.py b/tests/sampling/test_sampler_options_sampling_service.py index 423d53e..6cb4e4e 100644 --- a/tests/sampling/test_sampler_options_sampling_service.py +++ b/tests/sampling/test_sampler_options_sampling_service.py @@ -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 diff --git a/tools/attention_coupling_benchmark/comfy_probe/attention_coupling_phase_node.py b/tools/attention_coupling_benchmark/comfy_probe/attention_coupling_phase_node.py index 80f145e..d7d2a6b 100644 --- a/tools/attention_coupling_benchmark/comfy_probe/attention_coupling_phase_node.py +++ b/tools/attention_coupling_benchmark/comfy_probe/attention_coupling_phase_node.py @@ -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, ) diff --git a/web/dist/simple-syrup.js b/web/dist/simple-syrup.js index 6e64526..869e919 100644 --- a/web/dist/simple-syrup.js +++ b/web/dist/simple-syrup.js @@ -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); diff --git a/web/src/main.ts b/web/src/main.ts index d2aae75..f26b9c1 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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); diff --git a/web/src/samplerSocketOrder.ts b/web/src/samplerSocketOrder.ts new file mode 100644 index 0000000..4526bf5 --- /dev/null +++ b/web/src/samplerSocketOrder.ts @@ -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; + 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; + return node.constructor?.comfyClass === "SimpleSyrup.KSampler" && + Array.isArray(node.inputs) && + node.inputs.every((input) => typeof input.name === "string"); +} diff --git a/web/tests/sampling/samplerSocketOrder.test.ts b/web/tests/sampling/samplerSocketOrder.test.ts new file mode 100644 index 0000000..40992c2 --- /dev/null +++ b/web/tests/sampling/samplerSocketOrder.test.ts @@ -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); + } + ); +});