Extract Civitai metadata from image EXIF and improve subgraph widget resolution

This commit is contained in:
Dariusz L
2026-09-12 18:34:02 +02:00
parent 74e4b190a9
commit 18ef647c83
7 changed files with 620 additions and 117 deletions
+304 -35
View File
@@ -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": "",
}
+21
View File
@@ -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
+72
View File
@@ -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": []}]}
+98
View File
@@ -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")
+8 -29
View File
@@ -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);
}
+105 -48
View File
@@ -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'
+12 -5
View File
@@ -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>`;
}