From 18ef647c83cb14a34f4368c0505087dab1e362f0 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 12 Sep 2026 18:34:02 +0200 Subject: [PATCH] Extract Civitai metadata from image EXIF and improve subgraph widget resolution --- core/services/image_metadata_service.py | 339 ++++++++++++++++-- core/workflow/subgraphs.py | 21 ++ tests/test_image_metadata.py | 72 ++++ tests/test_workflow_components.py | 98 +++++ web/css/resolver-main.css | 37 +- .../views/tabs_image_metadata_methods.js | 153 +++++--- web/resolver/views/tabs_loaded_methods.js | 17 +- 7 files changed, 620 insertions(+), 117 deletions(-) diff --git a/core/services/image_metadata_service.py b/core/services/image_metadata_service.py index 95319ad..0e57a99 100644 --- a/core/services/image_metadata_service.py +++ b/core/services/image_metadata_service.py @@ -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": "", } diff --git a/core/workflow/subgraphs.py b/core/workflow/subgraphs.py index 25aeedd..ecfdfce 100644 --- a/core/workflow/subgraphs.py +++ b/core/workflow/subgraphs.py @@ -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 diff --git a/tests/test_image_metadata.py b/tests/test_image_metadata.py index c6d1724..c385474 100644 --- a/tests/test_image_metadata.py +++ b/tests/test_image_metadata.py @@ -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": []}]} diff --git a/tests/test_workflow_components.py b/tests/test_workflow_components.py index a676e7f..96607e8 100644 --- a/tests/test_workflow_components.py +++ b/tests/test_workflow_components.py @@ -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") diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 32095c9..8d15e82 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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); } diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index fd396c3..b221619 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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 = {

Image Metadata

-

Inspect workflow data embedded in an image or loaded from JSON.

+

Paste workflow JSON or drop an image or JSON file anywhere in this panel.

-
-
Drop an image or workflow JSON here
-
PNG, JPEG, WebP, or ComfyUI UI/API JSON
-
- - -
-
- - +
- + - +
+ + +
@@ -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' diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 701b232..429c214 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -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}Copy `; 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 = `
-

Loaded Models ${total}

-

${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}

+

${this.escapeHtml(title)} ${total}

+

${this.escapeHtml(modelSubtitle)}

@@ -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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += `
`; } @@ -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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += ``; }