Files
Will Miao 749c71976a feat(workflows): resolve base model when creating library entries
Newly created workflow entries used to be hardcoded to "Unknown". The
base model is now resolved at creation time only (Create LM-managed
workflow and first-time scans); write-back never re-resolves it.

Resolution order:
1. generation-time hint: on create with a prompt context, the primary
   checkpoint from the metadata collector (primary-sampler tracing plus
   lm_marker_role marks) outranks static analysis when it resolves in
   the local checkpoint library
2. static votes: majority base model of the referenced checkpoints in
   the local library, falling back to the referenced LoRAs
3. CivitAI URN fallback for one-off user actions (never scans)

Workflow analysis now extracts structured loader model references from
both API-format and UI-format graphs, including loaders whose widget
values are serialized as arrays.
2026-10-31 07:57:29 +08:00

208 lines
7.3 KiB
Python

"""Tests for py.utils.workflow_analysis: sniffing and graph extraction."""
import json
from py.utils.workflow_analysis import (
WORKFLOW_FORMAT_API,
WORKFLOW_FORMAT_UI,
analyze_workflow,
analyze_workflow_file,
load_workflow_file,
sniff_workflow_text,
)
UI_WORKFLOW = {
"nodes": [
{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": ["sd_xl_base_1.0.safetensors"]},
{"id": 2, "type": "LoraLoader", "widgets_values": ["civitai:12345@67890", 1.0, 1.0]},
{"id": 3, "type": "KSampler", "widgets_values": [42]},
],
"links": [],
"groups": [],
"version": 0.4,
}
API_WORKFLOW = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "v1-5-pruned.safetensors"}},
"2": {"class_type": "LoraLoader", "inputs": {"lora_name": "civitai:111@222", "strength_model": 1.0}},
"3": {"class_type": "SaveImage", "inputs": {"filename_prefix": "ComfyUI"}},
}
def test_sniff_ui_format():
assert sniff_workflow_text(json.dumps(UI_WORKFLOW)) == WORKFLOW_FORMAT_UI
def test_sniff_api_format():
assert sniff_workflow_text(json.dumps(API_WORKFLOW)) == WORKFLOW_FORMAT_API
def test_sniff_rejects_non_workflow_json():
assert sniff_workflow_text(json.dumps({"setting": True, "other": 1})) is None
assert sniff_workflow_text(json.dumps(["a", "b"])) is None
assert sniff_workflow_text("not json at all") is None
# A sidecar-shaped document (flat metadata fields) is not a workflow.
assert sniff_workflow_text(json.dumps({"file_name": "x", "sha256": "abc"})) is None
def test_analyze_ui_format_extracts_classes_and_refs():
analysis = analyze_workflow(UI_WORKFLOW)
assert analysis is not None
assert analysis.workflow_format == WORKFLOW_FORMAT_UI
assert analysis.node_count == 3
assert analysis.node_classes == ["CheckpointLoaderSimple", "KSampler", "LoraLoader"]
assert analysis.model_refs == ["sd_xl_base_1.0.safetensors"]
assert analysis.civitai_refs == ["civitai:12345@67890"]
def test_analyze_api_format_extracts_classes_and_refs():
analysis = analyze_workflow(API_WORKFLOW)
assert analysis is not None
assert analysis.workflow_format == WORKFLOW_FORMAT_API
assert analysis.node_count == 3
assert "SaveImage" in analysis.node_classes
assert analysis.model_refs == ["v1-5-pruned.safetensors"]
assert analysis.civitai_refs == ["civitai:111@222"]
def test_analyze_rejects_non_workflow():
assert analyze_workflow({"foo": "bar"}) is None
def test_analyze_skips_prompt_text_with_extension_like_suffix(tmp_path):
# Prompt strings contain spaces and must not become model refs.
workflow = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "a beautiful landscape, best quality.safetensors style"},
}
}
analysis = analyze_workflow(workflow)
assert analysis is not None
assert analysis.model_refs == []
def test_analyze_workflow_file_roundtrip(tmp_path):
path = tmp_path / "flow.json"
path.write_text(json.dumps(API_WORKFLOW), encoding="utf-8")
analysis = analyze_workflow_file(str(path))
assert analysis is not None
assert analysis.workflow_format == WORKFLOW_FORMAT_API
def test_load_workflow_file_rejects_sidecar(tmp_path):
sidecar = tmp_path / "flow.metadata.json"
sidecar.write_text(json.dumps({"file_name": "flow", "sha256": "x"}), encoding="utf-8")
assert load_workflow_file(str(sidecar)) is None
def test_load_workflow_file_handles_missing(tmp_path):
assert load_workflow_file(str(tmp_path / "nope.json")) is None
def test_analyze_api_format_extracts_primary_model_refs():
workflow = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "My Model v2.safetensors"},
},
"2": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "flux1-dev.safetensors", "weight_dtype": "default"},
},
# LoRA loaders and encoders are not base-model loaders.
"3": {"class_type": "LoraLoader", "inputs": {"lora_name": "detail.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat.safetensors"}},
}
analysis = analyze_workflow(workflow)
assert analysis is not None
# Loader-attributed refs accept file names with spaces, which the generic
# model_refs scan rejects to keep prompt text out.
assert analysis.primary_model_refs == [
"My Model v2.safetensors",
"flux1-dev.safetensors",
]
# The space-carrying name stays out of the generic refs; the LoRA and the
# prompt-like string behave as before.
assert analysis.model_refs == ["detail.safetensors", "flux1-dev.safetensors"]
def test_analyze_ui_format_extracts_primary_model_refs():
workflow = {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"widgets_values": ["SDXL/My Model.safetensors"],
},
{
"id": 2,
"type": "UNETLoader",
"widgets_values": ["flux dev.safetensors", "default"],
},
# LoRA loaders are not base-model loaders.
{
"id": 3,
"type": "LoraLoader",
"widgets_values": ["style.safetensors", 1.0, 1.0],
},
# A model name fed through a non-loader node (e.g. a converted
# widget's PrimitiveNode source) is not attributed to a loader.
{
"id": 4,
"type": "PrimitiveNode",
"widgets_values": ["wired checkpoint.safetensors"],
},
],
"links": [],
}
analysis = analyze_workflow(workflow)
assert analysis is not None
assert analysis.primary_model_refs == [
"SDXL/My Model.safetensors",
"flux dev.safetensors",
]
def test_analyze_primary_refs_empty_without_loader_nodes():
analysis = analyze_workflow({"1": {"class_type": "KSampler", "inputs": {"seed": 1}}})
assert analysis is not None
assert analysis.primary_model_refs == []
def test_analyze_recognizes_lora_manager_display_name_loaders():
"""LoRA Manager nodes register their display NAME ("Checkpoint Loader
(LoraManager)") as the workflow JSON type — not the Python class name."""
ui_workflow = {
"nodes": [
{
"id": 1,
"type": "Checkpoint Loader (LoraManager)",
"widgets_values": ["Illustrious/anime/waiIllustriousSDXL_v140.safetensors"],
},
{
"id": 2,
"type": "Unet Loader (LoraManager)",
"widgets_values": ["Qwen/qwenImageEdit_fp8.safetensors", "default"],
},
],
"links": [],
}
analysis = analyze_workflow(ui_workflow)
assert analysis is not None
assert analysis.primary_model_refs == [
"Illustrious/anime/waiIllustriousSDXL_v140.safetensors",
"Qwen/qwenImageEdit_fp8.safetensors",
]
api_workflow = {
"1": {
"class_type": "Checkpoint Loader (LoraManager)",
"inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors", "base_model": "Any"},
},
}
analysis = analyze_workflow(api_workflow)
assert analysis is not None
assert analysis.primary_model_refs == ["sd_xl_base_1.0.safetensors"]