b2a05e0351
Omitting the seed was already random. Passing -1 was not: it went through the uint64 wrap and arrived as 18446744073709551615, which is a perfectly valid *fixed* seed -- so "give me something new" returned the identical picture every time, silently, and the retry loop would have redrawn the same rejected image until it ran out of attempts. -1 is what ComfyUI's own interface uses for random, and A1111, and everything else that has ever asked somebody for a seed. A model that has read any of them will write it, so the one reading that had to work was the one that did not. Any negative value, not only -1, because the sentinel is the *idea* rather than the number and a model that writes -2 means the same thing. Zero stays a real seed: it is the boundary this change could easily have swallowed, and it is one somebody deliberately picks. Confirmed against the real ComfyUI: -1 now sends a random uint64 that it accepts and draws from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
"""Filling a ComfyUI template.
|
|
|
|
The thing worth pinning here is types. ComfyUI validates its inputs, so a
|
|
workflow whose `steps` arrives as the string "20" is refused -- and the refusal
|
|
happens on somebody's machine a minute later rather than in a test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import lembas
|
|
from lembas.services.images import workflow as wf
|
|
|
|
BASE = json.loads(
|
|
(Path(lembas.__file__).parent / "services/images/base_workflow.json").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
|
|
|
|
def _filled(**given):
|
|
return wf.fill(BASE, wf.resolve({"prompt": "a bicycle", **given}))
|
|
|
|
|
|
# --- Substitution --------------------------------------------------------------
|
|
def test_a_whole_placeholder_keeps_its_type():
|
|
"""`"steps": "{{steps}}"` has to become the number 20, not the text "20".
|
|
Substituting textually is the obvious implementation and produces a document
|
|
ComfyUI refuses."""
|
|
sampler = _filled(steps=25, cfg=7.5, seed=42)["3"]["inputs"]
|
|
|
|
assert sampler["steps"] == 25
|
|
assert isinstance(sampler["steps"], int)
|
|
assert sampler["cfg"] == 7.5
|
|
assert isinstance(sampler["cfg"], float)
|
|
assert sampler["seed"] == 42
|
|
assert isinstance(sampler["seed"], int)
|
|
|
|
|
|
def test_a_placeholder_inside_a_string_is_text():
|
|
"""Which is what makes `"{{prompt}}, masterpiece"` a usable template."""
|
|
filled = wf.fill(
|
|
{"n": {"inputs": {"text": "{{prompt}}, masterpiece"}}}, wf.resolve({"prompt": "a cat"})
|
|
)
|
|
assert filled["n"]["inputs"]["text"] == "a cat, masterpiece"
|
|
|
|
|
|
def test_the_prompt_and_the_negative_go_to_different_nodes():
|
|
filled = _filled(negative="blurry")
|
|
assert filled["6"]["inputs"]["text"] == "a bicycle"
|
|
assert filled["7"]["inputs"]["text"] == "blurry"
|
|
|
|
|
|
def test_the_links_between_nodes_survive():
|
|
"""A workflow is a graph, and `["4", 0]` is an edge rather than a value. A
|
|
filler that walked only dicts would flatten every one of them."""
|
|
filled = _filled()
|
|
assert filled["3"]["inputs"]["model"] == ["4", 0]
|
|
assert filled["8"]["inputs"]["vae"] == ["4", 2]
|
|
assert filled["5"]["inputs"]["batch_size"] == 1
|
|
|
|
|
|
def test_an_unknown_placeholder_is_left_alone():
|
|
"""`prompts.substitute`'s rule. A literal `{{x}}` is not a feature, but
|
|
silently deleting one is worse than leaving it where somebody can see it."""
|
|
filled = wf.fill({"n": {"inputs": {"lora": "{{lora_name}}"}}}, wf.resolve({"prompt": "x"}))
|
|
assert filled["n"]["inputs"]["lora"] == "{{lora_name}}"
|
|
|
|
|
|
def test_the_shipped_template_uses_every_placeholder_it_should():
|
|
assert wf.placeholders_in(BASE) == set(wf.PLACEHOLDERS)
|
|
|
|
|
|
# --- Resolution ----------------------------------------------------------------
|
|
def test_what_is_not_asked_for_takes_its_default():
|
|
values = wf.resolve({"prompt": "a bicycle"})
|
|
assert values["steps"] == 20
|
|
assert values["cfg"] == 8.0
|
|
assert values["width"] == 512
|
|
assert values["negative"] == "text, watermark"
|
|
assert values["sampler"] == "euler"
|
|
|
|
|
|
def test_the_seed_is_random_when_nobody_names_one():
|
|
"""A fixed default would make every unspecified generation identical -- and
|
|
would make the retry loop produce the same rejected image four times."""
|
|
seeds = {wf.resolve({"prompt": "x"})["seed"] for _ in range(8)}
|
|
assert len(seeds) == 8
|
|
|
|
|
|
def test_a_named_seed_is_kept():
|
|
assert wf.resolve({"prompt": "x", "seed": 1234})["seed"] == 1234
|
|
|
|
|
|
@pytest.mark.parametrize("sentinel", [-1, -5, "-1"])
|
|
def test_a_negative_seed_means_random(sentinel):
|
|
"""`-1` is what ComfyUI's own interface, A1111 and everything else that has
|
|
ever asked for a seed use for "surprise me", so a model that has read any of
|
|
them will write it.
|
|
|
|
Without this it went through the uint64 wrap and came out as
|
|
18446744073709551615 -- a perfectly valid *fixed* seed, so asking for
|
|
something new produced the same picture every time.
|
|
"""
|
|
seeds = {wf.resolve({"prompt": "x", "seed": sentinel})["seed"] for _ in range(8)}
|
|
assert len(seeds) == 8
|
|
assert all(0 <= seed <= wf.MAX_SEED for seed in seeds)
|
|
|
|
|
|
def test_seed_zero_is_a_real_seed():
|
|
"""It is the boundary the negative test could easily have swallowed, and
|
|
zero is a seed somebody deliberately picks."""
|
|
assert wf.resolve({"prompt": "x", "seed": 0})["seed"] == 0
|
|
|
|
|
|
@pytest.mark.parametrize("empty", [None, ""])
|
|
def test_null_and_empty_mean_no_opinion(empty):
|
|
"""A model that emits `"steps": null` rather than omitting the key is common
|
|
enough that reading it as a request for zero steps would be a bug nobody
|
|
could see."""
|
|
assert wf.resolve({"prompt": "x", "steps": empty})["steps"] == 20
|
|
|
|
|
|
def test_out_of_range_numbers_are_clamped_rather_than_refused():
|
|
"""A model asking for 300 steps has misjudged rather than misbehaved, and a
|
|
clarifying round costs more than doing the sensible thing."""
|
|
values = wf.resolve({"prompt": "x", "steps": 5000, "cfg": -4, "denoise": 12})
|
|
assert values["steps"] == 150
|
|
assert values["cfg"] == 0.0
|
|
assert values["denoise"] == 1.0
|
|
|
|
|
|
def test_nonsense_falls_back_instead_of_raising():
|
|
"""Arguments come from a model, so "twenty" is a thing that will arrive."""
|
|
assert wf.resolve({"prompt": "x", "steps": "twenty"})["steps"] == 20
|
|
|
|
|
|
def test_a_seed_larger_than_comfyui_allows_is_wrapped():
|
|
assert 0 <= wf.resolve({"prompt": "x", "seed": wf.MAX_SEED + 5})["seed"] <= wf.MAX_SEED
|