Files
AEmotionStudio-ComfyUI-Shad…/tests/test_node_dispatch.py
T
Æmotion StudioandClaude Opus 5 7b40c556d4 feat: expose temporal_coherent and correct the parameter tooltips
The Direct node's shader_type list offered three of the four generators this
repository ships. temporal_coherent was registered, covered by the test suite
and reachable from both the standard and legacy dispatch paths -- but absent
from the combo, so no workflow could select it. Nothing tied the advertised
list to the registry, so nothing caught it.

It is the generator best suited to video: 4D simplex with time as a real axis
rather than a per-frame redraw. On real MiniMax H3 weights it degrades far more
gracefully than the others (faint background striping at strength 0.35, where
domain_warp already shows blocking and curl_noise has heavy colour casts), and
it is the only type whose audio level stays flat as strength rises: -24.0 /
-23.7 / -23.7 dB against a -22.6 dB baseline, where domain_warp climbs to -17.0.

Two tests now pin the dropdown and the registry to each other in both
directions. The second fails on the previous list, naming temporal_coherent.

The tooltips implied the whole 0.0-1.0 strength range was usable. Measured on
H3 at 608x352 and 8 steps it is not:

    domain_warp + multiply      photoreal to ~0.25, gone by 0.75
    tensor_field                tolerates ~0.5
    curl_noise                  ~0.2, heavy colour casts by 0.5
    soft_light / difference     clean at 0.3, the gentlest blends
    add                         ~0.15, the most aggressive
    shape masks                 ~0.2; at 0.6 the mask is drawn into the picture
    use_temporal_coherence      ~0.2; 0.5 swamps the frame

use_temporal_coherence was the worst offender: it claimed to help "maintain
frame-to-frame consistency", when holding one seed across frames stops the
pattern varying between them and so reinforces it rather than averaging it out.
The tooltips now carry these ceilings, rank the blend modes by aggressiveness,
and note that the live preview only draws three of the four types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 01:32:41 -07:00

135 lines
5.4 KiB
Python

"""
The node routes to the right pipeline, and standard mode samples one trajectory.
Measured through the node with a recording sampler:
standard, 1 stage 1 call x 20 steps, from sigma 14.61
standard, 2 stages 2 calls x 10 steps, from 14.61 then 1.48
standard, denoise 0.6 1 call x 20 steps, from 2.23
standard, 3 injections 4 calls x 5 steps, 14.61 / 3.87 / 1.48 / 0.60
legacy, 2 stages 2 calls, each building its own full schedule
The second sigma in the two-stage standard run is the point: legacy restarted
every stage at 14.61, which on flow models discards the previous stage entirely.
"""
from unittest import mock
import pytest
import torch
import comfy.sample
from helpers import FakeModel
from snk.direct_shader_ksampler import DirectShaderNoiseKSampler
@pytest.fixture
def sampler_calls():
calls = []
def fake_sample(model, noise, steps, cfg, sampler_name, scheduler, positive, negative,
latent_image, denoise=1.0, disable_noise=False, start_step=None, last_step=None,
force_full_denoise=False, noise_mask=None, sigmas=None, callback=None,
disable_pbar=False, seed=None):
calls.append({
"steps": steps,
"sigmas": None if sigmas is None else sigmas.detach().clone(),
"denoise": denoise,
})
result = latent_image + 0.1 * noise
if callback is not None:
callback(max(steps - 1, 0), result * 0.5, result, steps)
return result
with mock.patch.object(comfy.sample, "sample", fake_sample):
yield calls
def run_node(**overrides):
kwargs = dict(
model=FakeModel("eps"), seed=8888, steps=20, cfg=7.0, sampler_name="euler",
scheduler="normal", positive=[], negative=[],
latent_image={"samples": torch.zeros(1, 4, 16, 16)}, denoise=1.0,
sequential_stages=1, injection_stages=0, shader_strength=0.3, blend_mode="multiply",
noise_transform="none", use_temporal_coherence=False, shader_type="domain_warp",
shape_type="none", color_scheme="none", noise_scale=1.0, octaves=1.0,
warp_strength=0.5, shape_mask_strength=1.0, phase_shift=0.5, color_intensity=0.8,
)
kwargs.update(overrides)
return DirectShaderNoiseKSampler().sample(**kwargs)
@pytest.mark.parametrize("mode", ["standard", "legacy"])
def test_both_modes_return_a_latent(sampler_calls, mode):
result = run_node(sampling_mode=mode)
assert "result" in result and isinstance(result["result"], tuple)
assert result["result"][0]["samples"].shape == (1, 4, 16, 16)
def test_standard_mode_samples_one_schedule(sampler_calls):
run_node(sampling_mode="standard", sequential_stages=2)
assert len(sampler_calls) == 2
first, second = sampler_calls
assert first["steps"] == second["steps"] == 10
assert first["sigmas"] is not None
# The second segment continues where the first stopped instead of restarting.
assert float(second["sigmas"][0]) < float(first["sigmas"][0])
assert torch.equal(first["sigmas"][-1], second["sigmas"][0])
def test_legacy_mode_still_builds_its_own_schedule(sampler_calls):
"""The frozen path passes no sigmas, so KSampler rebuilds a full one per stage."""
run_node(sampling_mode="legacy", sequential_stages=2)
assert len(sampler_calls) == 2
assert all(call["sigmas"] is None for call in sampler_calls)
def test_denoise_only_reaches_the_schedule_in_standard_mode(sampler_calls):
run_node(sampling_mode="standard", denoise=1.0)
full_start = float(sampler_calls[0]["sigmas"][0])
sampler_calls.clear()
run_node(sampling_mode="standard", denoise=0.6)
assert float(sampler_calls[0]["sigmas"][0]) < full_start
def test_injection_stages_never_leave_a_one_step_segment(sampler_calls):
run_node(sampling_mode="standard", injection_stages=3)
assert sum(call["steps"] for call in sampler_calls) == 20
assert all(call["steps"] >= 2 for call in sampler_calls)
def test_standard_mode_is_the_default(sampler_calls):
run_node(sequential_stages=2)
assert all(call["sigmas"] is not None for call in sampler_calls)
def test_every_advertised_shader_type_can_actually_be_resolved():
"""
The dropdown must not offer a pattern the generator registry cannot build.
It did: `temporal_coherent` shipped, was registered, and passed its own
tests, but was missing from the node's combo, so no workflow could select
it. Nothing tied the advertised list to the registry until this.
"""
from snk.core.shader_noise import resolve_generator
advertised = DirectShaderNoiseKSampler.INPUT_TYPES()["required"]["shader_type"][0]
assert advertised, "the node must advertise at least one shader type"
for shader_type in advertised:
assert callable(resolve_generator(shader_type)), shader_type
def test_the_node_offers_every_registered_generator():
"""The other direction: a shipped generator that no workflow can reach is dead weight."""
from snk.shaders.registry import get_shader, list_shaders
advertised = set(DirectShaderNoiseKSampler.INPUT_TYPES()["required"]["shader_type"][0])
# aliases point at a generator already reachable under its canonical name
canonical = {name for name in list_shaders()
if not any(get_shader(name) is get_shader(other) and other in advertised
for other in advertised)}
assert not canonical - advertised, f"registered but unreachable from the node: {sorted(canonical - advertised)}"