feat(sampling): add deterministic seed variation

This commit is contained in:
Artificial Sweetener
2026-08-30 21:00:45 -04:00
parent 4c13682df3
commit e8cbc6f724
14 changed files with 810 additions and 7 deletions
+3 -1
View File
@@ -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
+54
View File
@@ -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)
+14
View File
@@ -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."
)
+2
View File
@@ -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,
+97
View File
@@ -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,
),
)
+164
View File
@@ -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()
@@ -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()
+1
View File
@@ -57,6 +57,7 @@ BASE_NODE_IDS = [
"SimpleSyrup.ScaleFactor",
"SimpleSyrup.ConceptAttentionSEGS",
"SimpleSyrup.Seed",
"SimpleSyrup.SeedVariation",
"SimpleSyrup.SimpleLoadAnima",
"SimpleSyrup.SimplePreviewSEGS",
"SimpleSyrup.SimpleLoadCheckpoint",
+62
View File
@@ -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]
+269
View File
@@ -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,
)
+90
View File
@@ -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)
+3 -2
View File
@@ -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
+4 -3
View File
@@ -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
+2 -1
View File
@@ -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]]