Extract Civitai metadata from image EXIF and improve subgraph widget resolution
This commit is contained in:
@@ -4,6 +4,7 @@ import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -18,45 +19,129 @@ from ..workflow.formats import normalize_workflow_payload
|
||||
MAX_IMAGE_BYTES = 64 * 1024 * 1024
|
||||
MAX_WORKFLOW_JSON_BYTES = 16 * 1024 * 1024
|
||||
MAX_PARAMETERS_LENGTH = 12_000
|
||||
EXIF_IFD_TAG = 34665
|
||||
|
||||
_CIVITAI_JSON_LABELS = (
|
||||
"Civitai metadata",
|
||||
"Civitai resources",
|
||||
)
|
||||
_CIVITAI_CATEGORY_ALIASES = {
|
||||
"checkpoint": "checkpoints",
|
||||
"checkpoints": "checkpoints",
|
||||
"lora": "loras",
|
||||
"loras": "loras",
|
||||
"vae": "vae",
|
||||
"controlnet": "controlnet",
|
||||
"embedding": "embeddings",
|
||||
"embeddings": "embeddings",
|
||||
"textualinversion": "embeddings",
|
||||
"upscale": "upscale_models",
|
||||
"upscaler": "upscale_models",
|
||||
"upscalemodel": "upscale_models",
|
||||
}
|
||||
|
||||
|
||||
def _decode_metadata_value(value: Any) -> Any:
|
||||
def _decode_exif_user_comment(value: bytes) -> str:
|
||||
"""Decode the standard EXIF UserComment prefix and payload."""
|
||||
if value.startswith(b"UNICODE"):
|
||||
payload = value[7:].lstrip(b"\x00")
|
||||
if len(payload) % 2:
|
||||
payload += b"\x00"
|
||||
for encoding in ("utf-16le", "utf-16be"):
|
||||
try:
|
||||
return payload.decode(encoding).rstrip("\x00")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return payload.decode("utf-8", errors="replace").rstrip("\x00")
|
||||
if value.startswith(b"ASCII"):
|
||||
return value[5:].lstrip(b"\x00").rstrip(b"\x00").decode(
|
||||
"ascii", errors="replace"
|
||||
)
|
||||
if value.startswith(b"JIS"):
|
||||
return value[3:].lstrip(b"\x00").rstrip(b"\x00").decode(
|
||||
"shift_jis", errors="replace"
|
||||
)
|
||||
return value.decode("utf-8", errors="replace").rstrip("\x00")
|
||||
|
||||
|
||||
def _decode_metadata_value(value: Any, *, tag_name: str = "") -> Any:
|
||||
if isinstance(value, bytes):
|
||||
if str(tag_name).strip().lower().endswith("usercomment"):
|
||||
return _decode_exif_user_comment(value)
|
||||
try:
|
||||
return value.decode("utf-8")
|
||||
return value.decode("utf-8").rstrip("\x00")
|
||||
except UnicodeDecodeError:
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value.decode("utf-8", errors="replace").rstrip("\x00")
|
||||
return value
|
||||
|
||||
|
||||
def _metadata_json_value(value: Any) -> Any:
|
||||
current = _decode_metadata_value(value)
|
||||
for _ in range(3):
|
||||
if not isinstance(current, str):
|
||||
return current
|
||||
text = current.strip()
|
||||
if not text:
|
||||
return current
|
||||
if text[0] not in "[{\"":
|
||||
prefix, separator, remainder = text.partition(":")
|
||||
if separator and prefix.strip().lower() in {
|
||||
"prompt",
|
||||
"workflow",
|
||||
"comfy",
|
||||
"comfyui",
|
||||
}:
|
||||
text = remainder.strip()
|
||||
else:
|
||||
return current
|
||||
if not text:
|
||||
return current
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return current
|
||||
if not isinstance(parsed, str):
|
||||
return parsed
|
||||
current = parsed
|
||||
return current
|
||||
|
||||
|
||||
def _extract_labeled_json(value: Any, label: str) -> Any:
|
||||
"""Read a JSON value following a label inside free-form metadata text."""
|
||||
value = _decode_metadata_value(value)
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return value
|
||||
if text[0] not in "[{":
|
||||
prefix, separator, remainder = text.partition(":")
|
||||
if separator and prefix.strip().lower() in {
|
||||
"prompt",
|
||||
"workflow",
|
||||
"comfy",
|
||||
"comfyui",
|
||||
}:
|
||||
text = remainder.strip()
|
||||
else:
|
||||
return value
|
||||
if not text or text[0] not in "[{":
|
||||
return value
|
||||
return None
|
||||
match = re.search(
|
||||
rf"{re.escape(label)}\s*:\s*",
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
payload = value[match.end() :].lstrip()
|
||||
if not payload or payload[0] not in "[{":
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
parsed, _end = json.JSONDecoder().raw_decode(payload)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return value
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _safe_metadata_value(value: Any) -> Any:
|
||||
value = _decode_metadata_value(value)
|
||||
def _iter_metadata_payloads(value: Any):
|
||||
"""Yield direct, decoded, and labeled JSON payloads from one field."""
|
||||
yield value
|
||||
decoded = _metadata_json_value(value)
|
||||
if decoded is not value:
|
||||
yield decoded
|
||||
for label in _CIVITAI_JSON_LABELS:
|
||||
labeled = _extract_labeled_json(value, label)
|
||||
if labeled is not None:
|
||||
yield labeled
|
||||
|
||||
|
||||
def _safe_metadata_value(value: Any, *, tag_name: str = "") -> Any:
|
||||
value = _decode_metadata_value(value, tag_name=tag_name)
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
@@ -86,16 +171,33 @@ def _read_image_bytes(data_url: str) -> bytes:
|
||||
|
||||
def _image_metadata(image: Any) -> dict[str, Any]:
|
||||
metadata = {
|
||||
str(key): _safe_metadata_value(value)
|
||||
str(key): _safe_metadata_value(value, tag_name=str(key))
|
||||
for key, value in (image.info or {}).items()
|
||||
}
|
||||
|
||||
try:
|
||||
from PIL.ExifTags import TAGS
|
||||
from PIL import ExifTags
|
||||
|
||||
for tag_id, value in image.getexif().items():
|
||||
key = TAGS.get(tag_id, str(tag_id))
|
||||
metadata.setdefault(f"EXIF:{key}", _safe_metadata_value(value))
|
||||
tags = ExifTags.TAGS
|
||||
exif = image.getexif()
|
||||
|
||||
def add_exif_fields(fields):
|
||||
for tag_id, value in fields.items():
|
||||
tag_name = tags.get(tag_id, str(tag_id))
|
||||
metadata.setdefault(
|
||||
f"EXIF:{tag_name}",
|
||||
_safe_metadata_value(value, tag_name=tag_name),
|
||||
)
|
||||
|
||||
add_exif_fields(exif)
|
||||
try:
|
||||
exif_ifd = exif.get_ifd(
|
||||
getattr(getattr(ExifTags, "IFD", None), "Exif", EXIF_IFD_TAG)
|
||||
)
|
||||
add_exif_fields(exif_ifd)
|
||||
except Exception:
|
||||
# Some Pillow versions cannot traverse malformed nested IFDs.
|
||||
pass
|
||||
except Exception:
|
||||
# EXIF is optional and malformed EXIF must not hide valid PNG/WebP info.
|
||||
pass
|
||||
@@ -124,15 +226,152 @@ def _find_embedded_workflow(metadata: Mapping[str, Any]):
|
||||
)
|
||||
|
||||
for key, value in candidates:
|
||||
candidate = _metadata_json_value(value)
|
||||
try:
|
||||
normalized, workflow_format = normalize_workflow_payload(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
return normalized, workflow_format, key
|
||||
for candidate in _iter_metadata_payloads(value):
|
||||
try:
|
||||
normalized, workflow_format = normalize_workflow_payload(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
return normalized, workflow_format, key
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _coerce_number(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_identifier(value: Any) -> Any:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value).strip() or None
|
||||
|
||||
|
||||
def _first_mapping_value(mapping: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
|
||||
for key in keys:
|
||||
value = mapping.get(key)
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_civitai_resource(value: Any, index: int) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
|
||||
resource_type = str(
|
||||
_first_mapping_value(value, ("type", "model_type", "modelType")) or ""
|
||||
).strip()
|
||||
category_key = re.sub(r"[^a-z0-9]+", "", resource_type.lower())
|
||||
category = _CIVITAI_CATEGORY_ALIASES.get(category_key, category_key or "unknown")
|
||||
model_version_id = _coerce_identifier(
|
||||
_first_mapping_value(
|
||||
value,
|
||||
("modelVersionId", "model_version_id", "versionId", "version_id"),
|
||||
)
|
||||
)
|
||||
model_id = _coerce_identifier(
|
||||
_first_mapping_value(value, ("modelId", "model_id"))
|
||||
)
|
||||
name = str(
|
||||
_first_mapping_value(
|
||||
value,
|
||||
("modelName", "model_name", "name", "model"),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
version_name = str(
|
||||
_first_mapping_value(
|
||||
value,
|
||||
("modelVersionName", "model_version_name", "versionName", "version_name"),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if not name:
|
||||
name = version_name
|
||||
if not name and model_version_id is not None:
|
||||
name = f"Civitai model {model_version_id}"
|
||||
if not name:
|
||||
return None
|
||||
|
||||
strength = _coerce_number(
|
||||
_first_mapping_value(value, ("strength", "weight", "intensity"))
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"version_name": version_name,
|
||||
"category": category,
|
||||
"type": resource_type,
|
||||
"strength": strength,
|
||||
"model_id": model_id,
|
||||
"model_version_id": model_version_id,
|
||||
"source": "civitai_metadata",
|
||||
"active": strength is None or strength != 0,
|
||||
"connected": True,
|
||||
"resource_index": index,
|
||||
}
|
||||
|
||||
|
||||
def _merge_civitai_resources(values: list[Any]) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
by_identity = {}
|
||||
for index, value in enumerate(values):
|
||||
resource = _normalize_civitai_resource(value, index)
|
||||
if not resource:
|
||||
continue
|
||||
identity = (
|
||||
resource["model_version_id"]
|
||||
if resource["model_version_id"] is not None
|
||||
else f"{resource['category']}:{resource['name'].lower()}"
|
||||
)
|
||||
existing = by_identity.get(identity)
|
||||
if existing is None:
|
||||
by_identity[identity] = resource
|
||||
result.append(resource)
|
||||
continue
|
||||
for key, candidate in resource.items():
|
||||
if existing.get(key) in (None, "") and candidate not in (None, ""):
|
||||
existing[key] = candidate
|
||||
return result
|
||||
|
||||
|
||||
def _extract_civitai_metadata(
|
||||
metadata: Mapping[str, Any],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Extract Civitai's labeled generation metadata from EXIF text."""
|
||||
civitai_metadata: dict[str, Any] = {}
|
||||
resource_values = []
|
||||
|
||||
for key, value in metadata.items():
|
||||
key_token = re.sub(r"[^a-z0-9]+", "", str(key).lower())
|
||||
decoded = _metadata_json_value(value)
|
||||
if key_token == "civitaimetadata" and isinstance(
|
||||
decoded, Mapping
|
||||
):
|
||||
civitai_metadata.update(decoded)
|
||||
if "civitairesource" in key_token and isinstance(decoded, list):
|
||||
resource_values.extend(decoded)
|
||||
|
||||
labeled_metadata = _extract_labeled_json(value, "Civitai metadata")
|
||||
if isinstance(labeled_metadata, Mapping):
|
||||
civitai_metadata.update(labeled_metadata)
|
||||
labeled_resources = _extract_labeled_json(value, "Civitai resources")
|
||||
if isinstance(labeled_resources, list):
|
||||
resource_values.extend(labeled_resources)
|
||||
|
||||
nested_resources = civitai_metadata.get("resources")
|
||||
if isinstance(nested_resources, list):
|
||||
resource_values.extend(nested_resources)
|
||||
|
||||
return civitai_metadata, _merge_civitai_resources(resource_values)
|
||||
|
||||
|
||||
def _metadata_summary(
|
||||
metadata: Mapping[str, Any],
|
||||
*,
|
||||
@@ -147,6 +386,15 @@ def _metadata_summary(
|
||||
),
|
||||
None,
|
||||
)
|
||||
if parameters is None:
|
||||
parameters = next(
|
||||
(
|
||||
value
|
||||
for key, value in metadata.items()
|
||||
if str(key).strip().lower().endswith("usercomment")
|
||||
),
|
||||
None,
|
||||
)
|
||||
parameters = _decode_metadata_value(parameters)
|
||||
if parameters is not None and not isinstance(parameters, str):
|
||||
parameters = str(parameters)
|
||||
@@ -170,6 +418,17 @@ def inspect_image_bytes(raw: bytes, filename: str = "") -> dict[str, Any]:
|
||||
with Image.open(io.BytesIO(raw)) as image:
|
||||
image.load()
|
||||
metadata = _image_metadata(image)
|
||||
civitai_metadata, civitai_resources = _extract_civitai_metadata(metadata)
|
||||
if civitai_metadata:
|
||||
metadata.setdefault(
|
||||
"Civitai metadata",
|
||||
json.dumps(civitai_metadata, ensure_ascii=False),
|
||||
)
|
||||
if civitai_resources:
|
||||
metadata.setdefault(
|
||||
"Civitai resources",
|
||||
json.dumps(civitai_resources, ensure_ascii=False),
|
||||
)
|
||||
workflow, workflow_format, workflow_source = _find_embedded_workflow(
|
||||
metadata
|
||||
)
|
||||
@@ -193,6 +452,13 @@ def inspect_image_bytes(raw: bytes, filename: str = "") -> dict[str, Any]:
|
||||
"workflow_node_count": len(workflow.get("nodes", []))
|
||||
if isinstance(workflow, Mapping)
|
||||
else 0,
|
||||
"civitai_metadata": civitai_metadata,
|
||||
"civitai_resources": civitai_resources,
|
||||
"civitai_workflow": (
|
||||
str(civitai_metadata.get("workflow", ""))
|
||||
if isinstance(civitai_metadata.get("workflow"), str)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
except ValueError:
|
||||
raise
|
||||
@@ -236,6 +502,9 @@ def inspect_workflow_json(
|
||||
"workflow_format": workflow_format,
|
||||
"workflow_source": "json",
|
||||
"workflow_node_count": len(normalized.get("nodes", [])),
|
||||
"civitai_metadata": {},
|
||||
"civitai_resources": [],
|
||||
"civitai_workflow": "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,19 @@ def _get_widget_index_by_name(
|
||||
return None
|
||||
|
||||
widgets_values = node.get("widgets_values", [])
|
||||
|
||||
# ComfyUI can omit promoted/hidden inputs from ``inputs`` while keeping
|
||||
# their values in the positional array. ``widgets_values_named`` is the
|
||||
# serialized mapping that preserves the real positional index in that
|
||||
# case (for example: seed, value, model_name).
|
||||
named_values = node.get("widgets_values_named")
|
||||
if isinstance(named_values, dict):
|
||||
for index, name in enumerate(named_values):
|
||||
if index >= len(widgets_values):
|
||||
break
|
||||
if normalize_widget_name(name) == target_name:
|
||||
return index
|
||||
|
||||
for widget_index in range(len(widgets_values)):
|
||||
candidates = get_widget_name_candidates(node, widget_index)
|
||||
if any(
|
||||
@@ -215,6 +228,14 @@ def _build_promoted_widget_contexts(
|
||||
) -> None:
|
||||
if proxy_index is None or not proxy_widget_name or not targets:
|
||||
return
|
||||
|
||||
named_proxy_index = _get_widget_index_by_name(
|
||||
instance_node,
|
||||
proxy_widget_name,
|
||||
)
|
||||
if named_proxy_index is not None:
|
||||
proxy_index = named_proxy_index
|
||||
|
||||
if proxy_index in seen_proxy_indexes:
|
||||
return
|
||||
|
||||
|
||||
@@ -52,6 +52,17 @@ def png_with_metadata(metadata):
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def jpeg_with_nested_user_comment(comment):
|
||||
image = Image.new("RGB", (32, 16), color=(20, 30, 40))
|
||||
exif = Image.Exif()
|
||||
exif[34665] = {
|
||||
37510: b"UNICODE\x00\x00" + comment.encode("utf-16le"),
|
||||
}
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="JPEG", exif=exif)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def test_api_workflow_is_detected_and_normalized_for_existing_analyzer():
|
||||
api = api_workflow()
|
||||
|
||||
@@ -100,6 +111,67 @@ def test_image_without_workflow_still_returns_safe_image_metadata():
|
||||
assert result["metadata"]["parameters"] == "seed: 123, steps: 20"
|
||||
|
||||
|
||||
def test_jpeg_nested_exif_reads_civitai_metadata_and_resources():
|
||||
named_resources = [
|
||||
{
|
||||
"type": "checkpoint",
|
||||
"modelVersionId": 3072332,
|
||||
"modelName": "Krea 2 Turbo",
|
||||
"modelVersionName": "v1.0",
|
||||
},
|
||||
{
|
||||
"type": "lora",
|
||||
"weight": 0.5,
|
||||
"modelVersionId": 3220691,
|
||||
"modelName": "SNOFS",
|
||||
"modelVersionName": "Krea 2 v1.3D",
|
||||
},
|
||||
]
|
||||
civitai_metadata = {
|
||||
"workflow": "txt2img",
|
||||
"prompt": "a pilot",
|
||||
"resources": [
|
||||
{
|
||||
"modelVersionId": 3072332,
|
||||
"strength": 1,
|
||||
"type": "Checkpoint",
|
||||
},
|
||||
{
|
||||
"modelVersionId": 3220691,
|
||||
"strength": 0.5,
|
||||
"type": "LORA",
|
||||
},
|
||||
],
|
||||
}
|
||||
comment = (
|
||||
"a pilot\n"
|
||||
"Negative prompt: \n"
|
||||
"Steps: 8, Sampler: euler, CFG scale: 1\n"
|
||||
f"Civitai resources: {json.dumps(named_resources)}\n"
|
||||
f"Civitai metadata: {json.dumps(civitai_metadata)}"
|
||||
)
|
||||
|
||||
result = inspect_image_bytes(jpeg_with_nested_user_comment(comment), "civitai.jpg")
|
||||
|
||||
assert "EXIF:UserComment" in result["metadata"]["keys"]
|
||||
assert "Civitai metadata" in result["metadata"]["keys"]
|
||||
assert "Civitai resources" in result["metadata"]["keys"]
|
||||
assert result["metadata"]["parameters"].startswith("a pilot")
|
||||
assert result["civitai_workflow"] == "txt2img"
|
||||
assert result["workflow"] is None
|
||||
assert result["workflow_format"] == ""
|
||||
assert result["civitai_metadata"]["prompt"] == "a pilot"
|
||||
assert [resource["name"] for resource in result["civitai_resources"]] == [
|
||||
"Krea 2 Turbo",
|
||||
"SNOFS",
|
||||
]
|
||||
assert [resource["category"] for resource in result["civitai_resources"]] == [
|
||||
"checkpoints",
|
||||
"loras",
|
||||
]
|
||||
assert result["civitai_resources"][1]["strength"] == 0.5
|
||||
|
||||
|
||||
def test_json_inspection_accepts_ui_and_api_workflows():
|
||||
ui = {"nodes": [{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": []}]}
|
||||
|
||||
|
||||
@@ -265,6 +265,104 @@ def test_build_promoted_contexts_records_instance_and_inner_locators():
|
||||
assert contexts["inner_widget_names"][("subgraph-1", "10", "ckpt_name")]
|
||||
|
||||
|
||||
def test_promoted_context_uses_named_widget_indexes_when_inputs_omit_hidden_values():
|
||||
model_name = "4x_NMKD-Superscale-SP_178000_G.pth"
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 157,
|
||||
"type": "subgraph-1",
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{"name": "image", "link": 1},
|
||||
{"name": "positive", "link": 2},
|
||||
{"name": "negative", "link": 3},
|
||||
{"name": "vae", "link": 4},
|
||||
{"name": "model", "link": 5},
|
||||
{
|
||||
"name": "value",
|
||||
"widget": {"name": "value"},
|
||||
"link": None,
|
||||
},
|
||||
{
|
||||
"name": "model_name",
|
||||
"widget": {"name": "model_name"},
|
||||
"link": None,
|
||||
},
|
||||
],
|
||||
# The hidden seed is present in the positional values but not
|
||||
# in the visible instance inputs.
|
||||
"widgets_values": [27499, 1.2, model_name],
|
||||
"widgets_values_named": {
|
||||
"seed": 27499,
|
||||
"value": 1.2,
|
||||
"model_name": model_name,
|
||||
},
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-1",
|
||||
"name": "Second Pass (Upscale)",
|
||||
"inputs": [
|
||||
{"name": "seed", "linkIds": [10]},
|
||||
{"name": "value", "linkIds": [11]},
|
||||
{"name": "model_name", "linkIds": [12]},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 142,
|
||||
"type": "UpscaleModelLoader",
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model_name",
|
||||
"widget": {"name": "model_name"},
|
||||
"link": 12,
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"type": "UPSCALE_MODEL",
|
||||
"links": [20],
|
||||
}
|
||||
],
|
||||
"widgets_values": [model_name],
|
||||
"widgets_values_named": {"model_name": model_name},
|
||||
},
|
||||
{
|
||||
"id": 154,
|
||||
"type": "PrimitiveFloat",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"widget": {"name": "value"},
|
||||
"link": 11,
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets_values": [1.2],
|
||||
"widgets_values_named": {"value": 1.2},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
refs = analysis.analyze_workflow_models(workflow, available_models=[])
|
||||
|
||||
assert len(refs) == 1
|
||||
assert refs[0].original_path == model_name
|
||||
assert refs[0].category == "upscale_models"
|
||||
assert refs[0].node_id == 157
|
||||
assert refs[0].widget_index == 2
|
||||
assert refs[0].extra_value("promoted_inner_node_id") == 142
|
||||
assert refs[0].extra_value("promoted_widget_name") == "model_name"
|
||||
assert refs[0].extra_value("connected") is False
|
||||
|
||||
|
||||
def test_apply_instance_promoted_context_resolves_existing_model(tmp_path):
|
||||
model_path = tmp_path / "promoted.safetensors"
|
||||
model_path.write_bytes(b"model")
|
||||
|
||||
@@ -4785,6 +4785,12 @@ button.mr-info-link:disabled {
|
||||
/* Image Metadata Tab */
|
||||
.mr-image-inspector {
|
||||
min-height: 100%;
|
||||
transition: background 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.mr-image-inspector.is-dragover {
|
||||
background: rgba(114, 211, 138, 0.025);
|
||||
box-shadow: inset 0 0 0 1px var(--mr-accent);
|
||||
}
|
||||
|
||||
.mr-image-inspector-header {
|
||||
@@ -4793,13 +4799,12 @@ button.mr-info-link:disabled {
|
||||
|
||||
.mr-image-inspector-tools {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--mr-spacing-12);
|
||||
padding: var(--mr-spacing-12);
|
||||
border-bottom: 1px solid var(--mr-border);
|
||||
}
|
||||
|
||||
.mr-image-inspector-dropzone,
|
||||
.mr-image-inspector-paste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4811,28 +4816,6 @@ button.mr-info-link:disabled {
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.mr-image-inspector-dropzone {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 132px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.mr-image-inspector-dropzone:hover,
|
||||
.mr-image-inspector-dropzone.is-dragover {
|
||||
border-color: var(--mr-accent);
|
||||
background: rgba(114, 211, 138, 0.08);
|
||||
}
|
||||
|
||||
.mr-image-inspector-dropzone-title {
|
||||
color: var(--mr-text);
|
||||
font-size: var(--mr-font-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mr-image-inspector-dropzone-subtitle,
|
||||
.mr-image-inspector-paste label {
|
||||
color: var(--mr-text-muted);
|
||||
font-size: var(--mr-font-md);
|
||||
@@ -4841,7 +4824,7 @@ button.mr-info-link:disabled {
|
||||
.mr-image-inspector-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--mr-spacing-8);
|
||||
margin-top: var(--mr-spacing-4);
|
||||
}
|
||||
@@ -4869,10 +4852,6 @@ button.mr-info-link:disabled {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mr-image-inspector-paste .mr-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.mr-image-inspector-result {
|
||||
padding: 0 var(--mr-spacing-12) var(--mr-spacing-16);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,46 @@ function countLabel(count, singular, plural = `${singular}s`) {
|
||||
return `${value} ${value === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function buildCivitaiModelData(resources = []) {
|
||||
const models = resources.map((resource, index) => {
|
||||
const modelVersionId = resource?.model_version_id
|
||||
?? resource?.modelVersionId
|
||||
?? '';
|
||||
const rawStrength = resource?.strength;
|
||||
const numericStrength = Number(rawStrength);
|
||||
const strength = Number.isFinite(numericStrength) ? numericStrength : null;
|
||||
const name = String(
|
||||
resource?.name
|
||||
|| resource?.model_name
|
||||
|| resource?.modelName
|
||||
|| (modelVersionId ? `Civitai model ${modelVersionId}` : '')
|
||||
).trim();
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
category: String(resource?.category || 'unknown'),
|
||||
node_id: `civitai-resource-${modelVersionId || index}`,
|
||||
widget_index: null,
|
||||
node_type: 'Civitai metadata',
|
||||
node_title: String(resource?.version_name || resource?.versionName || '').trim(),
|
||||
exists: false,
|
||||
active: resource?.active !== false && (strength === null || strength !== 0),
|
||||
connected: true,
|
||||
strength,
|
||||
original_path: '',
|
||||
model_id: resource?.model_id ?? resource?.modelId ?? null,
|
||||
model_version_id: modelVersionId || null,
|
||||
source: 'civitai_metadata',
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
loaded_models: models,
|
||||
total: models.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const imageMetadataMethods = {
|
||||
loadImageMetadata() {
|
||||
if (!this.contentElement) return null;
|
||||
@@ -59,25 +99,19 @@ export const imageMetadataMethods = {
|
||||
<div class="mr-loaded-models-header mr-image-inspector-header">
|
||||
<div class="mr-loaded-title-block">
|
||||
<h3 class="mr-loaded-models-title">Image Metadata</h3>
|
||||
<p class="mr-loaded-models-subtitle">Inspect workflow data embedded in an image or loaded from JSON.</p>
|
||||
<p class="mr-loaded-models-subtitle">Paste workflow JSON or drop an image or JSON file anywhere in this panel.</p>
|
||||
</div>
|
||||
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear">Clear</button>
|
||||
</div>
|
||||
<div class="mr-image-inspector-tools">
|
||||
<div class="mr-image-inspector-dropzone" data-image-inspector-dropzone tabindex="0" role="button" aria-label="Drop an image or workflow JSON file">
|
||||
<div class="mr-image-inspector-dropzone-title">Drop an image or workflow JSON here</div>
|
||||
<div class="mr-image-inspector-dropzone-subtitle">PNG, JPEG, WebP, or ComfyUI UI/API JSON</div>
|
||||
<div class="mr-image-inspector-actions">
|
||||
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="choose-image">Load image</button>
|
||||
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="choose-json">Load JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" hidden data-image-inspector-input="image">
|
||||
<input type="file" accept="application/json,.json" hidden data-image-inspector-input="json">
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,application/json,.json" hidden data-image-inspector-input="file">
|
||||
<div class="mr-image-inspector-paste">
|
||||
<label for="mr-image-inspector-json-text">Or paste workflow JSON</label>
|
||||
<label for="mr-image-inspector-json-text">Paste workflow JSON</label>
|
||||
<textarea id="mr-image-inspector-json-text" class="mr-image-inspector-textarea" rows="5" placeholder="Paste a ComfyUI UI workflow or API prompt graph..."></textarea>
|
||||
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="analyze-paste">Analyze pasted JSON</button>
|
||||
<div class="mr-image-inspector-actions">
|
||||
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="analyze-paste">Analyze pasted JSON</button>
|
||||
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="choose-file">Load file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-image-inspector-result" data-image-inspector-result></div>
|
||||
@@ -94,20 +128,20 @@ export const imageMetadataMethods = {
|
||||
if (!root || root.dataset.imageInspectorBound === '1') return;
|
||||
root.dataset.imageInspectorBound = '1';
|
||||
|
||||
const imageInput = root.querySelector('[data-image-inspector-input="image"]');
|
||||
const jsonInput = root.querySelector('[data-image-inspector-input="json"]');
|
||||
const dropzone = root.querySelector('[data-image-inspector-dropzone]');
|
||||
|
||||
dropzone?.addEventListener('click', (event) => {
|
||||
if (event.target.closest?.('button')) return;
|
||||
imageInput?.click();
|
||||
});
|
||||
const fileInput = root.querySelector('[data-image-inspector-input="file"]');
|
||||
const setDragOver = (active) => {
|
||||
root.classList.toggle('is-dragover', active);
|
||||
};
|
||||
const isFileDrag = (event) => {
|
||||
const types = Array.from(event.dataTransfer?.types || []);
|
||||
return types.includes('Files') || Boolean(event.dataTransfer?.files?.length);
|
||||
};
|
||||
let dragDepth = 0;
|
||||
|
||||
root.addEventListener('click', (event) => {
|
||||
const action = event.target.closest?.('[data-image-inspector-action]')?.dataset?.imageInspectorAction;
|
||||
if (!action) return;
|
||||
if (action === 'choose-image') imageInput?.click();
|
||||
if (action === 'choose-json') jsonInput?.click();
|
||||
if (action === 'choose-file') fileInput?.click();
|
||||
if (action === 'clear') this.clearImageMetadata();
|
||||
if (action === 'analyze-paste') {
|
||||
const textarea = root.querySelector('.mr-image-inspector-textarea');
|
||||
@@ -115,35 +149,43 @@ export const imageMetadataMethods = {
|
||||
}
|
||||
});
|
||||
|
||||
imageInput?.addEventListener('change', (event) => {
|
||||
fileInput?.addEventListener('change', (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) void this.inspectImageFile(file);
|
||||
});
|
||||
jsonInput?.addEventListener('change', (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) void this.inspectWorkflowFile(file);
|
||||
});
|
||||
|
||||
dropzone?.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add('is-dragover');
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
dropzone?.addEventListener('dragleave', () => {
|
||||
dropzone.classList.remove('is-dragover');
|
||||
});
|
||||
dropzone?.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove('is-dragover');
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) void this.inspectImageMetadataFile(file);
|
||||
});
|
||||
dropzone?.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
|
||||
root.addEventListener('dragenter', (event) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
imageInput?.click();
|
||||
event.stopPropagation();
|
||||
dragDepth += 1;
|
||||
setDragOver(true);
|
||||
});
|
||||
root.addEventListener('dragover', (event) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragOver(true);
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
root.addEventListener('dragleave', (event) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0 || (event.relatedTarget && !root.contains(event.relatedTarget))) {
|
||||
setDragOver(false);
|
||||
}
|
||||
});
|
||||
root.addEventListener('drop', (event) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepth = 0;
|
||||
setDragOver(false);
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) void this.inspectImageMetadataFile(file);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -310,6 +352,11 @@ export const imageMetadataMethods = {
|
||||
const workflowFormat = metadata.workflow_format
|
||||
? String(metadata.workflow_format).toUpperCase()
|
||||
: 'Not found';
|
||||
const civitaiWorkflow = String(metadata.civitai_workflow || '').trim();
|
||||
const civitaiResources = Array.isArray(metadata.civitai_resources)
|
||||
? metadata.civitai_resources
|
||||
: [];
|
||||
const civitaiModelData = buildCivitaiModelData(civitaiResources);
|
||||
const metadataKeys = Array.isArray(metadata.metadata?.keys)
|
||||
? metadata.metadata.keys.join(', ')
|
||||
: '—';
|
||||
@@ -323,6 +370,10 @@ export const imageMetadataMethods = {
|
||||
['Workflow nodes', metadata.workflow
|
||||
? countLabel(metadata.workflow_node_count, 'node')
|
||||
: 'No embedded workflow'],
|
||||
civitaiWorkflow ? ['Civitai workflow', civitaiWorkflow] : null,
|
||||
civitaiResources.length
|
||||
? ['Civitai resources', countLabel(civitaiResources.length, 'resource')]
|
||||
: null,
|
||||
image.mode ? ['Color mode', image.mode] : null,
|
||||
['Metadata keys', metadataKeys],
|
||||
].filter(Boolean);
|
||||
@@ -356,7 +407,13 @@ export const imageMetadataMethods = {
|
||||
`;
|
||||
|
||||
const modelContainer = result.querySelector(`#${modelContainerId}`);
|
||||
if (!metadata.workflow) {
|
||||
if (!metadata.workflow && civitaiModelData.total) {
|
||||
this.displayLoadedModels(modelContainer, civitaiModelData, {
|
||||
title: 'Referenced Models',
|
||||
subtitle: 'Models reported by embedded Civitai metadata.',
|
||||
includeContextMenu: false,
|
||||
});
|
||||
} else if (!metadata.workflow) {
|
||||
modelContainer.innerHTML = this.renderStatusMessage(
|
||||
'No supported ComfyUI workflow was found in this image.',
|
||||
'info'
|
||||
|
||||
@@ -335,9 +335,12 @@ export const tabsLoadedMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
displayLoadedModels(container, data) {
|
||||
displayLoadedModels(container, data, options = {}) {
|
||||
const loadedModels = data.loaded_models || [];
|
||||
const total = data.total || 0;
|
||||
const title = String(options.title || 'Loaded Models');
|
||||
const subtitle = options.subtitle ? String(options.subtitle) : '';
|
||||
const includeContextMenu = options.includeContextMenu !== false;
|
||||
|
||||
if (total === 0) {
|
||||
container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info');
|
||||
@@ -365,12 +368,16 @@ export const tabsLoadedMethods = {
|
||||
${copyIcon}<span>Copy</span>
|
||||
</button>`;
|
||||
const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`;
|
||||
const modelContextAttrs = model => includeContextMenu
|
||||
? this.getContextMenuAttrs(this.getLoadedModelContext(model))
|
||||
: '';
|
||||
const modelSubtitle = subtitle || `${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}`;
|
||||
|
||||
let html = `
|
||||
<div class="mr-loaded-models-header">
|
||||
<div class="mr-loaded-title-block">
|
||||
<h3 class="mr-loaded-models-title">Loaded Models <span class="mr-loaded-total">${total}</span></h3>
|
||||
<p class="mr-loaded-models-subtitle">${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}</p>
|
||||
<h3 class="mr-loaded-models-title">${this.escapeHtml(title)} <span class="mr-loaded-total">${total}</span></h3>
|
||||
<p class="mr-loaded-models-subtitle">${this.escapeHtml(modelSubtitle)}</p>
|
||||
</div>
|
||||
<div class="mr-loaded-filter-row">
|
||||
<button class="mr-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all')">All (${activeCount + inactiveCount})</button>
|
||||
@@ -414,7 +421,7 @@ export const tabsLoadedMethods = {
|
||||
const { name, strength } = this.getModelNameAndStrength(model);
|
||||
const fullName = model.original_path || model.name || name;
|
||||
const modelKey = this.getLoadedModelDomKey(model);
|
||||
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
@@ -433,7 +440,7 @@ export const tabsLoadedMethods = {
|
||||
const { name, strength } = this.getModelNameAndStrength(model);
|
||||
const fullName = model.original_path || model.name || name;
|
||||
const modelKey = this.getLoadedModelDomKey(model);
|
||||
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${this.getContextMenuAttrs(this.getLoadedModelContext(model))}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user