Adds utils/generation_params.py, a line-for-line port of extractGenerationParams in web/js/comfy-metadata.js, the parser behind the gallery, admin and metadata pages. The scan and live monitor can use it so every surface reads prompts the same way. The JavaScript parser is unchanged. The port reproduces the JavaScript value semantics the result depends on (property order, truthiness, String(), trim(), strict equality, Map keys, JSON.parse number rounding, and the inputs on which it throws), and runs its recursive tracing on an explicit stack so crafted graphs cannot exhaust the Python stack. Tests: - behaviour tests for each tracing rule, with JavaScript results as expected values, including the visit-limit boundary; - golden vectors of the JavaScript parser's results for every named case and every PNG fixture, so the comparison runs without Node; - a live comparison against the JavaScript parser when Node is present; - a randomized differential test over 9,000 generated graphs and workflows, coherent and corrupted, including inputs where the JavaScript throws. Five deliberate porting mistakes (trim, truthiness, prepend handling, visit limit, pair slot) each fail the tests. On a 38,602-file output folder, all 18,491 files with a workflow give identical results.
445 lines
13 KiB
Python
445 lines
13 KiB
Python
"""Named workflow cases shared by the generation-parameter parser tests.
|
|
|
|
Each case is the raw metadata records a file would carry, as text, so the cases
|
|
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
|
|
case through the legacy browser parser too. Prompt texts and seeds are invented.
|
|
"""
|
|
|
|
import json
|
|
|
|
|
|
def _text(value):
|
|
return None if value is None else json.dumps(value)
|
|
|
|
|
|
def case(prompt=None, workflow=None):
|
|
return {"prompt": _text(prompt), "workflow": _text(workflow)}
|
|
|
|
|
|
def encoder(text, clip=("4", 1)):
|
|
return {
|
|
"class_type": "CLIPTextEncode",
|
|
"inputs": {"text": text, "clip": list(clip)},
|
|
}
|
|
|
|
|
|
def ksampler(positive, negative, model=("4", 0), **settings):
|
|
inputs = {
|
|
"seed": 5,
|
|
"steps": 20,
|
|
"cfg": 7,
|
|
"sampler_name": "euler",
|
|
"scheduler": "normal",
|
|
"denoise": 1.0,
|
|
"model": list(model),
|
|
"positive": list(positive),
|
|
"negative": list(negative),
|
|
"latent_image": ["2", 0],
|
|
}
|
|
inputs.update(settings)
|
|
return {"class_type": "KSampler", "inputs": inputs}
|
|
|
|
|
|
CHECKPOINT = {
|
|
"class_type": "CheckpointLoaderSimple",
|
|
"inputs": {"ckpt_name": "example.safetensors"},
|
|
}
|
|
LATENT = {
|
|
"class_type": "EmptyLatentImage",
|
|
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
|
}
|
|
|
|
|
|
def _basic():
|
|
return {
|
|
"4": CHECKPOINT,
|
|
"2": LATENT,
|
|
"6": encoder("a cat on a windowsill"),
|
|
"7": encoder("blurry, watermark"),
|
|
"3": ksampler(("6", 0), ("7", 0)),
|
|
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
|
|
}
|
|
|
|
|
|
def _prompt_manager(text, prepend="", append=""):
|
|
return {
|
|
"class_type": "PromptManager",
|
|
"inputs": {
|
|
"text": text,
|
|
"category": "",
|
|
"tags": "",
|
|
"search_text": "",
|
|
"prepend_text": prepend,
|
|
"append_text": append,
|
|
"clip": ["4", 1],
|
|
},
|
|
}
|
|
|
|
|
|
def _shared_prompt_pipeline():
|
|
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
|
|
return {
|
|
"1": CHECKPOINT,
|
|
"5": ksampler(
|
|
("25", 0),
|
|
("26", 0),
|
|
("1", 0),
|
|
seed=111,
|
|
steps=4,
|
|
cfg=1.0,
|
|
sampler_name="lcm",
|
|
latent_image=["20", 0],
|
|
),
|
|
"6": {
|
|
"class_type": "VAEDecode",
|
|
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
|
},
|
|
"11": {
|
|
"class_type": "VAEEncode",
|
|
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
|
|
},
|
|
"12": ksampler(
|
|
("25", 0),
|
|
("26", 0),
|
|
("1", 0),
|
|
seed=222,
|
|
steps=6,
|
|
cfg=1.0,
|
|
sampler_name="lcm",
|
|
denoise=0.45,
|
|
latent_image=["11", 0],
|
|
),
|
|
"13": {
|
|
"class_type": "VAEDecode",
|
|
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
|
|
},
|
|
"17": {
|
|
"class_type": "FaceDetailer",
|
|
"inputs": {
|
|
"seed": 333,
|
|
"steps": 4,
|
|
"cfg": 1.0,
|
|
"sampler_name": "lcm",
|
|
"image": ["13", 0],
|
|
"model": ["1", 0],
|
|
"clip": ["1", 1],
|
|
"vae": ["1", 2],
|
|
"positive": ["25", 0],
|
|
"negative": ["26", 0],
|
|
},
|
|
},
|
|
"20": {
|
|
"class_type": "EmptyLatentImage",
|
|
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
|
|
},
|
|
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
|
|
"26": _prompt_manager("blurry, watermark"),
|
|
"48": {
|
|
"class_type": "KikoSaveImage",
|
|
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
|
|
},
|
|
"49": {
|
|
"class_type": "KikoProjectFilePath",
|
|
"inputs": {"project_name": "example"},
|
|
},
|
|
}
|
|
|
|
|
|
def _custom_sampler(guider):
|
|
return {
|
|
"13": CHECKPOINT,
|
|
"20": encoder("a red fox in snow", clip=("13", 1)),
|
|
"21": encoder("lowres", clip=("13", 1)),
|
|
"22": guider,
|
|
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
|
|
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
|
|
"17": {
|
|
"class_type": "BasicScheduler",
|
|
"inputs": {
|
|
"scheduler": "karras",
|
|
"steps": 8,
|
|
"denoise": 1.0,
|
|
"model": ["13", 0],
|
|
},
|
|
},
|
|
"1300:8": {
|
|
"class_type": "SamplerCustomAdvanced",
|
|
"inputs": {
|
|
"noise": ["25", 0],
|
|
"guider": ["22", 0],
|
|
"sampler": ["16", 0],
|
|
"sigmas": ["17", 0],
|
|
"latent_image": ["5", 0],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _workflow_only():
|
|
return {
|
|
"nodes": [
|
|
{
|
|
"id": 4,
|
|
"type": "CheckpointLoaderSimple",
|
|
"mode": 0,
|
|
"inputs": [],
|
|
"widgets_values": ["example.safetensors"],
|
|
},
|
|
{
|
|
"id": 6,
|
|
"type": "CLIPTextEncode",
|
|
"mode": 0,
|
|
"inputs": [{"name": "clip", "link": 3}],
|
|
"widgets_values": ["a tidepool at noon"],
|
|
},
|
|
{
|
|
"id": 7,
|
|
"type": "CLIPTextEncode",
|
|
"mode": 0,
|
|
"inputs": [{"name": "clip", "link": 5}],
|
|
"widgets_values": ["noisy"],
|
|
},
|
|
{
|
|
"id": 10,
|
|
"type": "Reroute",
|
|
"mode": 0,
|
|
"inputs": [{"name": "", "link": 7}],
|
|
},
|
|
{
|
|
"id": 3,
|
|
"type": "KSampler",
|
|
"mode": 0,
|
|
"inputs": [
|
|
{"name": "model", "link": 1},
|
|
{"name": "positive", "link": 8},
|
|
{"name": "negative", "link": 4},
|
|
{"name": "latent_image", "link": 2},
|
|
],
|
|
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
|
|
},
|
|
{
|
|
"id": 11,
|
|
"type": "CLIPTextEncode",
|
|
"mode": 2,
|
|
"inputs": [],
|
|
"widgets_values": ["muted node text"],
|
|
},
|
|
],
|
|
"links": [
|
|
[1, 4, 0, 3, 0, "MODEL"],
|
|
[3, 4, 1, 6, 0, "CLIP"],
|
|
[5, 4, 1, 7, 0, "CLIP"],
|
|
[7, 6, 0, 10, 0, "CONDITIONING"],
|
|
[8, 10, 0, 3, 1, "CONDITIONING"],
|
|
[4, 7, 0, 3, 2, "CONDITIONING"],
|
|
{
|
|
"id": 2,
|
|
"origin_id": 99,
|
|
"origin_slot": 0,
|
|
"target_id": 3,
|
|
"target_slot": 3,
|
|
"type": "LATENT",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _chain(length):
|
|
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
|
|
previous = "1"
|
|
for index in range(length):
|
|
node_id = str(100 + index)
|
|
graph[node_id] = {
|
|
"class_type": "ConditioningPass",
|
|
"inputs": {"conditioning": [previous, 0]},
|
|
}
|
|
previous = node_id
|
|
graph["3"] = ksampler((previous, 0), ("1", 0))
|
|
return graph
|
|
|
|
|
|
CASES = {
|
|
"basic": case(_basic()),
|
|
"prompt-manager-prepend-append": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
|
|
"7": _prompt_manager(
|
|
"masterpiece, a dog", prepend="masterpiece", append="4k"
|
|
),
|
|
"3": ksampler(("6", 0), ("7", 0)),
|
|
}
|
|
),
|
|
"linked-text-concat": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
|
|
"11": {
|
|
"class_type": "StringConcatenate",
|
|
"inputs": {
|
|
"string_a": ["10", 0],
|
|
"string_b": "second part",
|
|
"delimiter": ", ",
|
|
"seed": 3,
|
|
"string_c": " ",
|
|
},
|
|
},
|
|
"6": {
|
|
"class_type": "CLIPTextEncode",
|
|
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
|
|
},
|
|
"7": encoder(""),
|
|
"3": ksampler(("6", 0), ("7", 0)),
|
|
}
|
|
),
|
|
"prompt-manager-pair": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"8": {
|
|
"class_type": "PromptManagerPair",
|
|
"inputs": {
|
|
"text": "pair positive",
|
|
"negative_text": "pair negative",
|
|
"prepend_text": "",
|
|
"append_text": "",
|
|
"clip": ["4", 1],
|
|
},
|
|
},
|
|
"3": ksampler(("8", 0), ("8", 1)),
|
|
}
|
|
),
|
|
"conditioning-passthrough": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"6": encoder("through a controlnet"),
|
|
"7": encoder("negative through it"),
|
|
"12": {
|
|
"class_type": "ControlNetApplyAdvanced",
|
|
"inputs": {
|
|
"positive": ["6", 0],
|
|
"negative": ["7", 0],
|
|
"control_net": ["13", 0],
|
|
},
|
|
},
|
|
"3": ksampler(("12", 0), ("12", 1)),
|
|
}
|
|
),
|
|
"first-sampler-in-js-key-order": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"6": encoder("from sampler twelve"),
|
|
"7": encoder("from sampler five"),
|
|
"x": ksampler(("6", 0), ("6", 0)),
|
|
"12": ksampler(("6", 0), ("6", 0), seed=12),
|
|
"5": ksampler(("7", 0), ("7", 0), seed=55),
|
|
}
|
|
),
|
|
"custom-sampler-cfg-guider": case(
|
|
_custom_sampler(
|
|
{
|
|
"class_type": "CFGGuider",
|
|
"inputs": {
|
|
"model": ["13", 0],
|
|
"positive": ["20", 0],
|
|
"negative": ["21", 0],
|
|
"cfg": 3.5,
|
|
},
|
|
}
|
|
)
|
|
),
|
|
"custom-sampler-basic-guider": case(
|
|
_custom_sampler(
|
|
{
|
|
"class_type": "BasicGuider",
|
|
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
|
|
}
|
|
)
|
|
),
|
|
"no-sampler-loose-settings": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"3": {
|
|
"class_type": "MyKSamplerVariant",
|
|
"inputs": {
|
|
"noise_seed": 9,
|
|
"steps": 12,
|
|
"cfg": 2,
|
|
"sampler_name": "ddim",
|
|
},
|
|
},
|
|
}
|
|
),
|
|
"primitive-seed": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"6": encoder("seeded"),
|
|
"30": {
|
|
"class_type": "Seed (rgthree)",
|
|
"inputs": {"other": "x", "seed": 77},
|
|
},
|
|
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
|
|
}
|
|
),
|
|
"workflow-only": case(workflow=_workflow_only()),
|
|
"overwritten-prompt-manager-text": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"25": _prompt_manager("same saved text"),
|
|
"26": _prompt_manager("same saved text"),
|
|
"3": ksampler(("25", 0), ("26", 0)),
|
|
},
|
|
{
|
|
"nodes": [
|
|
{
|
|
"id": 25,
|
|
"type": "PromptManager",
|
|
"widgets_values": ["the real positive"],
|
|
},
|
|
{
|
|
"id": 26,
|
|
"type": "PromptManager",
|
|
"widgets_values": ["the real negative"],
|
|
},
|
|
],
|
|
"links": [],
|
|
},
|
|
),
|
|
"conditioning-cycle": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"20": {
|
|
"class_type": "ConditioningCombine",
|
|
"inputs": {"conditioning_1": ["21", 0]},
|
|
},
|
|
"21": {
|
|
"class_type": "ConditioningCombine",
|
|
"inputs": {"conditioning_1": ["20", 0]},
|
|
},
|
|
"3": ksampler(("20", 0), ("21", 0)),
|
|
}
|
|
),
|
|
"chain-within-visit-limit": case(_chain(450)),
|
|
"chain-beyond-visit-limit": case(_chain(600)),
|
|
"chain-at-last-visit": case(_chain(499)),
|
|
"chain-one-past-last-visit": case(_chain(500)),
|
|
"blank-text-and-zero-seed": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"6": encoder(" "),
|
|
"7": encoder("kept negative"),
|
|
"3": ksampler(("6", 0), ("7", 0), seed=0),
|
|
}
|
|
),
|
|
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
|
|
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
|
|
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
|
|
"seed-beyond-double-precision": case(
|
|
{
|
|
"4": CHECKPOINT,
|
|
"6": encoder("x"),
|
|
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
|
|
}
|
|
),
|
|
"nothing": case(),
|
|
}
|