diff --git a/README.md b/README.md index 4447161..a4c8b23 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The pack now covers model loading, regional prompting and segmentation, high-res - ADetailer-style `[SEP]` prompt batches, masked conditioning, and regional samplers, with optional Prompt Control scheduling and LoRA hooks. - WD14 and external vision LLM tagging that stays aligned with the right regions. - Ordered image and mask loading, GPU Lanczos resizing, tiled VAE options, and provenance-aware latent tools. -- WebUI-inspired sampler and scheduler extras including A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and beta57. +- WebUI-inspired sampling extras including seed variation, A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and beta57. ## Contents @@ -152,6 +152,8 @@ The external LLM nodes use a configured OpenAI-compatible provider. **Tag SEGS w **KSampler (Extras)** adds the A1111/k-diffusion-style `euler_a_a1111` sampler, AYS SD1 and SDXL schedules, GITS, the `automatic_a1111` scheduler, and a local implementation of the RES4LYF beta57 preset. It keeps Comfy's regular seed handling, partial denoise behavior, progress callbacks, and conditioning inputs. +**Seed Variation** patches a MODEL so Comfy-native samplers mix their normal initial noise toward a second deterministic seed. Strength `0` keeps the sampler seed unchanged, while strength `1` uses variation-seed initial noise. Ancestral and SDE samplers continue to use the sampler seed for additional noise introduced after initialization. + The remaining utilities are **Latent Diagnostics**, **Scale Factor**, and **Seed**. Latent Diagnostics reports the latent shape, dtype, device, and tiled-sampling compatibility while passing it through unchanged. ## Settings and optional integrations diff --git a/simple_syrup/domain/seed_variation.py b/simple_syrup/domain/seed_variation.py new file mode 100644 index 0000000..9a0bd85 --- /dev/null +++ b/simple_syrup/domain/seed_variation.py @@ -0,0 +1,54 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Define validated seed-variation sampling settings.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite + +MIN_SEED = 0 +MAX_SEED = 0xFFFFFFFFFFFFFFFF +MIN_VARIATION_STRENGTH = 0.0 +MAX_VARIATION_STRENGTH = 1.0 + + +@dataclass(frozen=True, slots=True) +class SeedVariationSettings: + """Hold one deterministic initial-noise interpolation request.""" + + variation_seed: int + strength: float + + def __post_init__(self) -> None: + """Reject settings outside ComfyUI's public seed and strength ranges.""" + + if isinstance(self.variation_seed, bool) or not isinstance( + self.variation_seed, + int, + ): + raise TypeError("Variation seed must be an integer.") + if not MIN_SEED <= self.variation_seed <= MAX_SEED: + raise ValueError( + f"Variation seed must be between {MIN_SEED} and {MAX_SEED}." + ) + if isinstance(self.strength, bool) or not isinstance( + self.strength, + (int, float), + ): + raise TypeError("Variation strength must be a number.") + normalized_strength = float(self.strength) + if not isfinite(normalized_strength): + raise ValueError("Variation strength must be finite.") + if ( + not MIN_VARIATION_STRENGTH + <= normalized_strength + <= (MAX_VARIATION_STRENGTH) + ): + raise ValueError( + "Variation strength must be between " + f"{MIN_VARIATION_STRENGTH} and {MAX_VARIATION_STRENGTH}." + ) + object.__setattr__(self, "strength", normalized_strength) diff --git a/simple_syrup/nodes/tooltips.py b/simple_syrup/nodes/tooltips.py index f16619a..64fba61 100644 --- a/simple_syrup/nodes/tooltips.py +++ b/simple_syrup/nodes/tooltips.py @@ -115,6 +115,20 @@ SAMPLING_SEED = ( "Seed used to create sampling noise. Reusing it with matching settings makes " "results repeatable." ) +SEED_VARIATION_MODEL_INPUT = ( + "Model whose sampler-provided initial noise will receive seed variation." +) +VARIATION_SEED = ( + "Seed for the second noise pattern. Change it to explore another variation " + "direction from the sampler's seed." +) +VARIATION_STRENGTH = ( + "Noise interpolation strength. 0 keeps the sampler seed unchanged; 1 uses the " + "variation seed for initial noise." +) +SEED_VARIATION_MODEL_OUTPUT = ( + "Model that interpolates initial sampling noise toward the variation seed." +) SAMPLING_STEPS = ( "Number of denoising steps. More steps can add refinement but take longer." ) diff --git a/simple_syrup/nodes_v3/__init__.py b/simple_syrup/nodes_v3/__init__.py index 2c2d53c..341c303 100644 --- a/simple_syrup/nodes_v3/__init__.py +++ b/simple_syrup/nodes_v3/__init__.py @@ -63,6 +63,7 @@ def get_nodes() -> list[type[object]]: from .load_mask_batch import LoadMaskBatchV3 from .mask_to_segs import MaskToSEGSV3 from .scale_factor import ScaleFactorV3 + from .seed_variation import SeedVariationV3 from .simple_load_checkpoint import SimpleLoadCheckpointV3 from .simple_load_flux import SimpleLoadFluxV3 from .simple_load_flux2 import SimpleLoadFlux2V3 @@ -114,6 +115,7 @@ def get_nodes() -> list[type[object]]: ScaleFactorV3, ConceptAttentionSEGSV3, SeedV3, + SeedVariationV3, SimpleLoadAnimaV3, SimplePreviewSEGSV3, SimpleLoadCheckpointV3, diff --git a/simple_syrup/nodes_v3/seed_variation.py b/simple_syrup/nodes_v3/seed_variation.py new file mode 100644 index 0000000..23f2c88 --- /dev/null +++ b/simple_syrup/nodes_v3/seed_variation.py @@ -0,0 +1,97 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Expose seed variation as a Comfy v3 MODEL patch node.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any, ClassVar + +from ..domain.seed_variation import ( + MAX_SEED, + MAX_VARIATION_STRENGTH, + MIN_SEED, + MIN_VARIATION_STRENGTH, +) +from ..nodes import tooltips +from ..services.seed_variation_model_service import SEED_VARIATION_MODEL_SERVICE + +if TYPE_CHECKING: + + class _ComfyNodeBase: + """Type-checking base for Comfy v3 nodes.""" + + RETURN_TYPES: ClassVar[list[str]] + RETURN_NAMES: ClassVar[list[str]] + +else: + _ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode + +_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io + + +class SeedVariationV3(_ComfyNodeBase): + """Derive a MODEL that varies sampler-provided initial noise.""" + + @classmethod + def define_schema(cls) -> Any: + """Declare the seed-variation MODEL patch contract.""" + + return _comfy_io.Schema( + node_id="SimpleSyrup.SeedVariation", + display_name="Seed Variation", + category="SimpleSyrup/Sampling", + description=( + "Creates related generations by mixing sampler noise toward a " + "second deterministic seed." + ), + search_aliases=["variation seed", "subseed", "seed interpolation"], + inputs=[ + _comfy_io.Model.Input( + "model", + tooltip=tooltips.SEED_VARIATION_MODEL_INPUT, + ), + _comfy_io.Int.Input( + "variation_seed", + default=0, + min=MIN_SEED, + max=MAX_SEED, + control_after_generate=True, + tooltip=tooltips.VARIATION_SEED, + ), + _comfy_io.Float.Input( + "variation_strength", + default=0.0, + min=MIN_VARIATION_STRENGTH, + max=MAX_VARIATION_STRENGTH, + step=0.01, + round=0.01, + tooltip=tooltips.VARIATION_STRENGTH, + ), + ], + outputs=[ + _comfy_io.Model.Output( + "model", + tooltip=tooltips.SEED_VARIATION_MODEL_OUTPUT, + ) + ], + ) + + @classmethod + def execute( + cls, + model: object, + variation_seed: int, + variation_strength: float, + ) -> tuple[object]: + """Return the source or a MODEL carrying initial-noise variation.""" + + return ( + SEED_VARIATION_MODEL_SERVICE.prepare( + model=model, + variation_seed=variation_seed, + variation_strength=variation_strength, + ), + ) diff --git a/simple_syrup/runtime/seed_variation.py b/simple_syrup/runtime/seed_variation.py new file mode 100644 index 0000000..29963a5 --- /dev/null +++ b/simple_syrup/runtime/seed_variation.py @@ -0,0 +1,164 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Portions of this file are adapted from AUTOMATIC1111 stable-diffusion-webui. +# See third_party/manifest.toml and third_party/NOTICE.md. + +"""Apply deterministic seed variation at ComfyUI's outer sampling boundary.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + +import torch +from comfy.patcher_extension import WrappersMP + +from ..domain.seed_variation import SeedVariationSettings +from .model_patcher_mutations import ModelKeyedWrapperMutation +from .patcher_lifecycle import PATCHER_LIFECYCLE + +SEED_VARIATION_WRAPPER_KEY = "simple_syrup.seed_variation" +_SLERP_EPSILON = 1e-6 + + +class SeedVariationNoiseInterpolator: + """Generate and interpolate one variation tensor without global RNG mutation.""" + + def interpolate( + self, + base_noise: torch.Tensor, + settings: SeedVariationSettings, + ) -> torch.Tensor: + """Return base-to-variation spherical interpolation for one sampling call.""" + + self._validate_noise(base_noise) + if settings.strength == 0.0 or self._is_disabled_noise(base_noise): + return base_noise + + variation_noise = self._variation_noise_like( + base_noise, + settings.variation_seed, + ) + if settings.strength == 1.0: + return variation_noise + return self._slerp(base_noise, variation_noise, settings.strength) + + @staticmethod + def _validate_noise(noise: torch.Tensor) -> None: + """Require the dense floating batched tensor used by Comfy samplers.""" + + if not isinstance(noise, torch.Tensor): + raise TypeError("Seed variation requires sampler noise as a torch.Tensor.") + if not noise.is_floating_point(): + raise TypeError("Seed variation requires floating-point sampler noise.") + if noise.layout is not torch.strided: + raise TypeError("Seed variation requires dense strided sampler noise.") + if noise.ndim < 2: + raise ValueError("Seed variation requires batched sampler noise.") + + @staticmethod + def _is_disabled_noise(noise: torch.Tensor) -> bool: + """Preserve an explicit all-zero noise request from the sampling node.""" + + return noise.numel() == 0 or not bool(torch.count_nonzero(noise).item()) + + @staticmethod + def _variation_noise_like(noise: torch.Tensor, seed: int) -> torch.Tensor: + """Create Comfy-compatible seeded CPU noise and move it to the base tensor.""" + + generator = torch.Generator(device="cpu") + generator.manual_seed(seed) + variation = torch.randn( + tuple(noise.shape), + dtype=torch.float32, + device="cpu", + generator=generator, + ) + return variation.to(device=noise.device, dtype=noise.dtype) + + @staticmethod + def _slerp( + base_noise: torch.Tensor, + variation_noise: torch.Tensor, + strength: float, + ) -> torch.Tensor: + """Spherically interpolate each batch item with stable linear fallbacks.""" + + batch_size = int(base_noise.shape[0]) + base_flat = base_noise.to(dtype=torch.float32).reshape(batch_size, -1) + variation_flat = variation_noise.to(dtype=torch.float32).reshape( + batch_size, + -1, + ) + base_norm = torch.linalg.vector_norm(base_flat, dim=1, keepdim=True) + variation_norm = torch.linalg.vector_norm( + variation_flat, + dim=1, + keepdim=True, + ) + valid_norms = (base_norm > _SLERP_EPSILON) & (variation_norm > _SLERP_EPSILON) + base_unit = base_flat / base_norm.clamp_min(_SLERP_EPSILON) + variation_unit = variation_flat / variation_norm.clamp_min(_SLERP_EPSILON) + cosine = (base_unit * variation_unit).sum(dim=1, keepdim=True) + cosine = cosine.clamp(min=-1.0, max=1.0) + angle = torch.acos(cosine) + sine = torch.sin(angle) + stable_angle = sine.abs() > _SLERP_EPSILON + + base_weight = torch.sin((1.0 - strength) * angle) / sine.clamp_min( + _SLERP_EPSILON + ) + variation_weight = torch.sin(strength * angle) / sine.clamp_min(_SLERP_EPSILON) + spherical = base_weight * base_flat + variation_weight * variation_flat + linear = torch.lerp(base_flat, variation_flat, strength) + mixed = torch.where(valid_norms & stable_angle, spherical, linear) + return mixed.reshape_as(base_noise).to(dtype=base_noise.dtype) + + +@dataclass(frozen=True, slots=True) +class SeedVariationOuterSampleWrapper: + """Replace only the initial sampler noise before Comfy prepares the model.""" + + settings: SeedVariationSettings + interpolator: SeedVariationNoiseInterpolator = field( + default_factory=SeedVariationNoiseInterpolator + ) + + def __call__( + self, + executor: Callable[..., object], + noise: torch.Tensor, + *args: object, + **kwargs: object, + ) -> object: + """Forward sampling with interpolated noise and every other argument intact.""" + + varied_noise = self.interpolator.interpolate(noise, self.settings) + return executor(varied_noise, *args, **kwargs) + + +class SeedVariationModelPatchBackend: + """Derive a MODEL carrying one namespaced outer-sample wrapper.""" + + def derive(self, model: object, settings: SeedVariationSettings) -> object: + """Return the source for a no-op or a lifecycle-safe patched MODEL.""" + + if settings.strength == 0.0: + return model + wrapper = SeedVariationOuterSampleWrapper(settings) + return PATCHER_LIFECYCLE.derive_model( + model, + ( + ModelKeyedWrapperMutation( + wrapper_type=WrappersMP.OUTER_SAMPLE, + key=SEED_VARIATION_WRAPPER_KEY, + wrapper=wrapper, + ), + ), + operation="seed variation MODEL patch", + ) + + +SEED_VARIATION_MODEL_PATCH_BACKEND = SeedVariationModelPatchBackend() diff --git a/simple_syrup/services/seed_variation_model_service.py b/simple_syrup/services/seed_variation_model_service.py new file mode 100644 index 0000000..81c4d24 --- /dev/null +++ b/simple_syrup/services/seed_variation_model_service.py @@ -0,0 +1,45 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Prepare a sampling MODEL with deterministic initial-noise variation.""" + +from __future__ import annotations + +from ..domain.seed_variation import SeedVariationSettings +from ..runtime.seed_variation import SEED_VARIATION_MODEL_PATCH_BACKEND +from ..shared.logging import get_logger + +LOGGER = get_logger(__name__) + + +class SeedVariationModelService: + """Validate seed variation and delegate MODEL derivation to the runtime backend.""" + + def prepare( + self, + *, + model: object, + variation_seed: int, + variation_strength: float, + ) -> object: + """Return a MODEL that varies the sampler's initial noise when enabled.""" + + settings = SeedVariationSettings( + variation_seed=variation_seed, + strength=variation_strength, + ) + derived = SEED_VARIATION_MODEL_PATCH_BACKEND.derive(model, settings) + LOGGER.info( + "Seed variation MODEL prepared", + extra={ + "operation": "seed_variation_model_patch", + "variation_seed": variation_seed, + "variation_strength": settings.strength, + "enabled": settings.strength > 0.0, + }, + ) + return derived + + +SEED_VARIATION_MODEL_SERVICE = SeedVariationModelService() diff --git a/tests/test_registration.py b/tests/test_registration.py index 60d58f7..8a82ca3 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -57,6 +57,7 @@ BASE_NODE_IDS = [ "SimpleSyrup.ScaleFactor", "SimpleSyrup.ConceptAttentionSEGS", "SimpleSyrup.Seed", + "SimpleSyrup.SeedVariation", "SimpleSyrup.SimpleLoadAnima", "SimpleSyrup.SimplePreviewSEGS", "SimpleSyrup.SimpleLoadCheckpoint", diff --git a/tests/test_seed_variation_domain.py b/tests/test_seed_variation_domain.py new file mode 100644 index 0000000..a3797e7 --- /dev/null +++ b/tests/test_seed_variation_domain.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 + +"""Tests for validated seed-variation settings.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.domain.seed_variation import ( + MAX_SEED, + SeedVariationSettings, +) + + +def test_seed_variation_settings_normalize_valid_values() -> None: + """Keep the full Comfy seed range and normalize numeric strength to float.""" + + settings = SeedVariationSettings(variation_seed=MAX_SEED, strength=1) + + assert settings.variation_seed == MAX_SEED + assert settings.strength == 1.0 + assert isinstance(settings.strength, float) + + +@pytest.mark.parametrize("variation_seed", [-1, MAX_SEED + 1]) +def test_seed_variation_settings_reject_out_of_range_seeds( + variation_seed: int, +) -> None: + """Reject seeds outside ComfyUI's public unsigned 64-bit range.""" + + with pytest.raises(ValueError, match="Variation seed must be between"): + SeedVariationSettings(variation_seed=variation_seed, strength=0.5) + + +@pytest.mark.parametrize("variation_seed", [True, 1.5, "1"]) +def test_seed_variation_settings_reject_non_integer_seeds( + variation_seed: object, +) -> None: + """Reject boolean and coercible values at the domain boundary.""" + + with pytest.raises(TypeError, match="Variation seed must be an integer"): + SeedVariationSettings(variation_seed=variation_seed, strength=0.5) # type: ignore[arg-type] + + +@pytest.mark.parametrize("strength", [-0.01, 1.01, float("inf"), float("nan")]) +def test_seed_variation_settings_reject_invalid_strength(strength: float) -> None: + """Reject non-finite and out-of-range interpolation strengths.""" + + with pytest.raises(ValueError, match="Variation strength must"): + SeedVariationSettings(variation_seed=1, strength=strength) + + +@pytest.mark.parametrize("strength", [True, "0.5", None]) +def test_seed_variation_settings_reject_non_numeric_strength( + strength: object, +) -> None: + """Reject values that would require implicit numeric coercion.""" + + with pytest.raises(TypeError, match="Variation strength must be a number"): + SeedVariationSettings(variation_seed=1, strength=strength) # type: ignore[arg-type] diff --git a/tests/test_seed_variation_runtime.py b/tests/test_seed_variation_runtime.py new file mode 100644 index 0000000..5438ea5 --- /dev/null +++ b/tests/test_seed_variation_runtime.py @@ -0,0 +1,269 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for seed variation at ComfyUI's outer sampling boundary.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import torch +from comfy.patcher_extension import WrappersMP + +from simple_syrup.domain.seed_variation import SeedVariationSettings +from simple_syrup.runtime.seed_variation import ( + SEED_VARIATION_WRAPPER_KEY, + SeedVariationModelPatchBackend, + SeedVariationNoiseInterpolator, + SeedVariationOuterSampleWrapper, +) + + +def test_strength_zero_and_disabled_noise_are_exact_passthroughs() -> None: + """Avoid allocation and preserve explicit sampler noise-disable behavior.""" + + interpolator = SeedVariationNoiseInterpolator() + base_noise = torch.randn((2, 4, 8, 8), generator=_generator(10)) + disabled_noise = torch.zeros_like(base_noise) + + unchanged = interpolator.interpolate( + base_noise, + SeedVariationSettings(variation_seed=20, strength=0.0), + ) + disabled = interpolator.interpolate( + disabled_noise, + SeedVariationSettings(variation_seed=20, strength=1.0), + ) + + assert unchanged is base_noise + assert disabled is disabled_noise + + +def test_strength_one_matches_comfy_noise_without_global_rng_mutation() -> None: + """Use one local CPU generator and make the variation endpoint exact.""" + + base_noise = torch.randn((2, 4, 3, 5), generator=_generator(11)) + expected = torch.randn( + base_noise.shape, + dtype=torch.float32, + generator=_generator(29), + ) + torch.manual_seed(101) + expected_global_next = torch.randn((3,)) + torch.manual_seed(101) + + result = SeedVariationNoiseInterpolator().interpolate( + base_noise, + SeedVariationSettings(variation_seed=29, strength=1.0), + ) + actual_global_next = torch.randn((3,)) + + assert torch.equal(result, expected) + assert torch.equal(actual_global_next, expected_global_next) + + +def test_interpolation_is_deterministic_per_batch_item_for_video_noise() -> None: + """Preserve shape and dtype while applying canonical per-item SLERP.""" + + base_noise = torch.randn( + (2, 4, 3, 5, 7), + dtype=torch.float64, + generator=_generator(37), + ) + settings = SeedVariationSettings(variation_seed=41, strength=0.35) + interpolator = SeedVariationNoiseInterpolator() + + first = interpolator.interpolate(base_noise, settings) + second = interpolator.interpolate(base_noise, settings) + variation = torch.randn( + base_noise.shape, + dtype=torch.float32, + generator=_generator(41), + ).to(dtype=base_noise.dtype) + expected = _reference_slerp(base_noise, variation, settings.strength) + + assert first.shape == base_noise.shape + assert first.dtype == base_noise.dtype + assert torch.equal(first, second) + assert torch.allclose(first, expected, atol=1e-6, rtol=1e-6) + assert not torch.equal(first[0], first[1]) + + +def test_identical_noise_uses_stable_linear_fallback() -> None: + """Avoid undefined spherical division for collinear seeded tensors.""" + + variation_seed = 53 + base_noise = torch.randn( + (1, 4, 6, 6), + dtype=torch.float32, + generator=_generator(variation_seed), + ) + + result = SeedVariationNoiseInterpolator().interpolate( + base_noise, + SeedVariationSettings(variation_seed=variation_seed, strength=0.5), + ) + + assert torch.all(torch.isfinite(result)) + assert torch.allclose(result, base_noise) + + +@pytest.mark.parametrize( + ("noise", "message"), + [ + (torch.ones((1, 2), dtype=torch.int64), "floating-point"), + (torch.ones((4,), dtype=torch.float32), "batched"), + ], +) +def test_interpolator_rejects_unsupported_noise_tensors( + noise: torch.Tensor, + message: str, +) -> None: + """Fail clearly before unsupported sampler noise enters interpolation.""" + + with pytest.raises((TypeError, ValueError), match=message): + SeedVariationNoiseInterpolator().interpolate( + noise, + SeedVariationSettings(variation_seed=1, strength=0.5), + ) + + +def test_outer_sample_wrapper_changes_only_noise_argument() -> None: + """Forward sampler, latent, callbacks, and base seed without modification.""" + + base_noise = torch.randn((1, 4, 4, 4), generator=_generator(61)) + latent = torch.zeros_like(base_noise) + sampler = object() + sigmas = torch.tensor([1.0, 0.0]) + callback = object() + calls: list[tuple[torch.Tensor, tuple[object, ...], dict[str, object]]] = [] + + def executor( + noise: torch.Tensor, + *args: object, + **kwargs: object, + ) -> str: + """Record wrapper forwarding and return a stable sentinel.""" + + calls.append((noise, args, kwargs)) + return "sampled" + + wrapper = SeedVariationOuterSampleWrapper( + SeedVariationSettings(variation_seed=67, strength=1.0) + ) + result = wrapper( + executor, + base_noise, + latent, + sampler, + sigmas, + None, + callback, + False, + 1234, + latent_shapes=[tuple(latent.shape)], + ) + + assert result == "sampled" + assert len(calls) == 1 + varied_noise, forwarded_args, forwarded_kwargs = calls[0] + assert torch.equal( + varied_noise, + torch.randn(base_noise.shape, generator=_generator(67)), + ) + assert forwarded_args == ( + latent, + sampler, + sigmas, + None, + callback, + False, + 1234, + ) + assert forwarded_kwargs == {"latent_shapes": [tuple(latent.shape)]} + + +def test_model_patch_backend_uses_one_collision_safe_outer_wrapper() -> None: + """Preserve source state and direct lineage through the native wrapper surface.""" + + source = _patcher() + backend = SeedVariationModelPatchBackend() + settings = SeedVariationSettings(variation_seed=71, strength=0.4) + + derived = cast(Any, backend.derive(source, settings)) + + assert derived is not source + assert derived.parent is source + assert ( + source.get_wrappers(WrappersMP.OUTER_SAMPLE, SEED_VARIATION_WRAPPER_KEY) == [] + ) + wrappers = derived.get_wrappers( + WrappersMP.OUTER_SAMPLE, + SEED_VARIATION_WRAPPER_KEY, + ) + assert len(wrappers) == 1 + assert isinstance(wrappers[0], SeedVariationOuterSampleWrapper) + assert wrappers[0].settings == settings + + +def test_model_patch_backend_returns_source_when_variation_is_disabled() -> None: + """Avoid a redundant MODEL clone when strength makes the operation a no-op.""" + + source = _patcher() + + result = cast( + Any, + SeedVariationModelPatchBackend().derive( + source, + SeedVariationSettings(variation_seed=79, strength=0.0), + ), + ) + + assert result is source + assert source.wrappers == {} + + +def _generator(seed: int) -> torch.Generator: + """Return an isolated CPU generator for deterministic expectations.""" + + return torch.Generator(device="cpu").manual_seed(seed) + + +def _reference_slerp( + base_noise: torch.Tensor, + variation_noise: torch.Tensor, + strength: float, +) -> torch.Tensor: + """Calculate independent canonical batch SLERP for behavior comparison.""" + + outputs: list[torch.Tensor] = [] + for base_item, variation_item in zip(base_noise, variation_noise, strict=True): + base_flat = base_item.float().flatten() + variation_flat = variation_item.float().flatten() + cosine = torch.dot( + base_flat / torch.linalg.vector_norm(base_flat), + variation_flat / torch.linalg.vector_norm(variation_flat), + ).clamp(-1.0, 1.0) + angle = torch.acos(cosine) + sine = torch.sin(angle) + mixed = ( + torch.sin((1.0 - strength) * angle) / sine * base_flat + + torch.sin(strength * angle) / sine * variation_flat + ) + outputs.append(mixed.reshape_as(base_item).to(dtype=base_item.dtype)) + return torch.stack(outputs) + + +def _patcher() -> Any: + """Create a real CPU Comfy MODEL patcher for wrapper integration.""" + + from comfy.model_patcher import ModelPatcher + + device = torch.device("cpu") + return ModelPatcher( + torch.nn.Linear(1, 1), + load_device=device, + offload_device=device, + ) diff --git a/tests/test_seed_variation_v3_node.py b/tests/test_seed_variation_v3_node.py new file mode 100644 index 0000000..c85b108 --- /dev/null +++ b/tests/test_seed_variation_v3_node.py @@ -0,0 +1,90 @@ +# SimpleSyrup - workflow-focused ComfyUI extensions for image generation +# Copyright (C) 2026 Artificial Sweetener and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the Seed Variation Comfy v3 MODEL patch node.""" + +from __future__ import annotations + +import pytest + +from simple_syrup.nodes import tooltips +from simple_syrup.nodes_v3 import seed_variation +from simple_syrup.nodes_v3.seed_variation import SeedVariationV3 + + +def test_seed_variation_v3_schema_exposes_complete_model_patch_contract() -> None: + """Pin the public id, controls, descriptions, and MODEL output metadata.""" + + schema = SeedVariationV3.define_schema() + + assert schema.node_id == "SimpleSyrup.SeedVariation" + assert schema.display_name == "Seed Variation" + assert schema.category == "SimpleSyrup/Sampling" + assert "mixing sampler noise" in schema.description + assert [item.id for item in schema.inputs] == [ + "model", + "variation_seed", + "variation_strength", + ] + model_input, seed_input, strength_input = schema.inputs + assert model_input.io_type == "MODEL" + assert model_input.tooltip == tooltips.SEED_VARIATION_MODEL_INPUT + assert seed_input.io_type == "INT" + assert seed_input.default == 0 + assert seed_input.min == 0 + assert seed_input.max == 0xFFFFFFFFFFFFFFFF + assert seed_input.control_after_generate is True + assert seed_input.tooltip == tooltips.VARIATION_SEED + assert strength_input.io_type == "FLOAT" + assert strength_input.default == 0.0 + assert strength_input.min == 0.0 + assert strength_input.max == 1.0 + assert strength_input.step == 0.01 + assert strength_input.tooltip == tooltips.VARIATION_STRENGTH + assert [output.id for output in schema.outputs] == ["model"] + assert schema.outputs[0].io_type == "MODEL" + assert schema.outputs[0].tooltip == tooltips.SEED_VARIATION_MODEL_OUTPUT + + +def test_seed_variation_v3_execute_delegates_to_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep the Comfy-facing node thin and return the service result exactly.""" + + source = object() + derived = object() + calls: list[dict[str, object]] = [] + + class RecordingService: + """Record one node-to-service request.""" + + def prepare(self, **kwargs: object) -> object: + """Capture keyword arguments and return a stable MODEL sentinel.""" + + calls.append(kwargs) + return derived + + monkeypatch.setattr( + seed_variation, + "SEED_VARIATION_MODEL_SERVICE", + RecordingService(), + ) + + result = SeedVariationV3.execute(source, 83, 0.25) + + assert result == (derived,) + assert calls == [ + { + "model": source, + "variation_seed": 83, + "variation_strength": 0.25, + } + ] + + +def test_seed_variation_v3_execute_surfaces_invalid_settings() -> None: + """Expose actionable domain validation before a MODEL can be mutated.""" + + with pytest.raises(ValueError, match="Variation strength must be between"): + SeedVariationV3.execute(object(), 1, 1.5) diff --git a/tests/test_third_party_vendoring_contract.py b/tests/test_third_party_vendoring_contract.py index 85f22f8..a824216 100644 --- a/tests/test_third_party_vendoring_contract.py +++ b/tests/test_third_party_vendoring_contract.py @@ -91,7 +91,7 @@ def test_automatic1111_sampler_integration_provenance_is_recorded() -> None: ) components = {component["name"]: component for component in manifest["component"]} - automatic1111 = components["AUTOMATIC1111 Euler a sampler integration"] + automatic1111 = components["AUTOMATIC1111 sampling integration"] license_path = REPO_ROOT / automatic1111["license_file"] assert automatic1111["license"] == "AGPL-3.0" @@ -111,6 +111,7 @@ def test_automatic1111_sampler_integration_provenance_is_recorded() -> None: "simple_syrup/runtime/a1111_sampling.py", "simple_syrup/runtime/sampling_samplers.py", "simple_syrup/runtime/sampling_schedulers.py", + "simple_syrup/runtime/seed_variation.py", ] @@ -187,7 +188,7 @@ def test_notice_records_sampler_and_tiled_diffusion_provenance() -> None: notice = (REPO_ROOT / "third_party" / "NOTICE.md").read_text(encoding="utf-8") - assert "AUTOMATIC1111 Euler a sampler integration" in notice + assert "AUTOMATIC1111 sampling integration" in notice assert "k-diffusion Euler ancestral sampler" in notice assert "Mixture of Diffusers and MultiDiffusion tiled diffusion behavior" in notice assert "regional prompt mask blending" in notice diff --git a/third_party/NOTICE.md b/third_party/NOTICE.md index ef1fd92..6eb4ffb 100644 --- a/third_party/NOTICE.md +++ b/third_party/NOTICE.md @@ -25,12 +25,13 @@ The preset uses ComfyUI's beta scheduler with `alpha=0.5` and `beta=0.7`. SimpleSyrup resolves the preset locally for `KSampler (Extras)` and does not patch ComfyUI's global scheduler registry. -## AUTOMATIC1111 Euler a sampler integration +## AUTOMATIC1111 sampling integration SimpleSyrup vendors selected AUTOMATIC1111 WebUI sampler integration behavior under AGPL-3.0. This provenance covers the `Euler a` sampler mapping, the -`Automatic` scheduler fallback behavior, and the documented decision not to -port AUTOMATIC1111 ENSD or RNG hijacking behavior. +`Automatic` scheduler fallback behavior, and seed-variation interpolation at +ComfyUI's model-carried outer sampling boundary. SimpleSyrup does not port +AUTOMATIC1111 ENSD or global RNG hijacking behavior. ## k-diffusion Euler ancestral sampler diff --git a/third_party/manifest.toml b/third_party/manifest.toml index 9976252..d4a03af 100644 --- a/third_party/manifest.toml +++ b/third_party/manifest.toml @@ -76,7 +76,7 @@ vendored_files = [ ] [[component]] -name = "AUTOMATIC1111 Euler a sampler integration" +name = "AUTOMATIC1111 sampling integration" license = "AGPL-3.0" license_file = "third_party/licenses/automatic1111.LICENSE.txt" source = "https://github.com/AUTOMATIC1111/stable-diffusion-webui" @@ -91,6 +91,7 @@ vendored_files = [ "simple_syrup/runtime/a1111_sampling.py", "simple_syrup/runtime/sampling_samplers.py", "simple_syrup/runtime/sampling_schedulers.py", + "simple_syrup/runtime/seed_variation.py", ] [[component]]