Files
Dariusz L 794083b212 Support clipboard pasting in Metadata tab and resolve Civitai model filenames
- Add paste handler (Ctrl+V) in Metadata tab for workflow JSON, images, and Civitai image URLs/HTML
- Resolve authentic .safetensors filenames and local ComfyUI model paths via Civitai modelVersionId
- Normalize prompt <lora:...> tags to authentic filenames and enrich transfer/loaded chip labels
2026-10-16 22:57:04 +02:00

967 lines
32 KiB
Python

"""Read-only workflow metadata inspection for images and JSON files."""
import base64
import binascii
import io
import json
import os
import re
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional
from ..log_system import create_module_logger
from ..network_utils import execute_provider_json_request, request_source_response
from ..request_utils import (
read_optional_object_payload,
read_optional_text_field,
read_text_field,
)
from ..routes.context import RouteContext
from ..type_utils import (
CATEGORY_MAP,
DEFAULT_BROWSER_USER_AGENT,
as_dict,
as_list,
normalize_category_token,
)
from ..workflow.formats import normalize_workflow_payload
log = create_module_logger(__name__)
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",
)
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").rstrip("\x00")
except UnicodeDecodeError:
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 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:
parsed, _end = json.JSONDecoder().raw_decode(payload)
except (TypeError, ValueError, json.JSONDecodeError):
return None
return parsed
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)
def _read_image_bytes(data_url: str) -> bytes:
encoded = data_url.strip()
if encoded.startswith("data:"):
header, separator, encoded = encoded.partition(",")
if not separator or ";base64" not in header.lower():
raise ValueError("Image payload must be a base64 data URL")
encoded = "".join(encoded.split())
if not encoded:
raise ValueError("Image payload is empty")
if len(encoded) > ((MAX_IMAGE_BYTES + 2) * 4 // 3) + 16:
raise ValueError("Image payload is too large")
try:
raw = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Image payload is not valid base64") from exc
if not raw:
raise ValueError("Image payload is empty")
if len(raw) > MAX_IMAGE_BYTES:
raise ValueError("Image payload is too large")
return raw
def _image_metadata(image: Any) -> dict[str, Any]:
metadata = {
str(key): _safe_metadata_value(value, tag_name=str(key))
for key, value in (image.info or {}).items()
}
try:
from PIL import ExifTags
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
return metadata
def _find_embedded_workflow(metadata: Mapping[str, Any]):
priority_keys = (
"workflow",
"prompt",
"comfyui",
"comfy",
"extra_pnginfo",
"extra_metadata",
)
candidates = []
seen = set()
for key in priority_keys:
if key in metadata:
candidates.append((key, metadata[key]))
seen.add(key)
candidates.extend(
(key, value)
for key, value in metadata.items()
if key not in seen
)
for key, value in candidates:
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 = normalize_category_token(resource_type)
category = CATEGORY_MAP.get(category_key, category_key or "unknown")
model_version_id = _coerce_identifier(
_first_mapping_value(
value,
("modelVersionId", "model_version_id", "versionId", "version_id", "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
_CIVITAI_VERSION_FILES_CACHE: dict[int, dict[str, Any]] = {}
def get_civitai_version_file_info(
version_id: int,
api_key: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""Fetch primary file and model metadata for a Civitai modelVersionId."""
if not version_id:
return None
try:
vid = int(version_id)
except (TypeError, ValueError):
return None
if vid in _CIVITAI_VERSION_FILES_CACHE:
return _CIVITAI_VERSION_FILES_CACHE[vid]
try:
data = execute_provider_json_request(
"CivitAI version file lookup",
f"https://civitai.com/api/v1/model-versions/{vid}",
api_key=api_key,
timeout=10,
max_attempts=1,
raise_on_error=False,
)
except Exception as exc:
log.warning(f"Failed to lookup Civitai model version {vid}: {exc}")
return None
if not isinstance(data, dict):
return None
files = as_list(data.get("files"))
primary_file = None
for f in files:
if isinstance(f, dict) and f.get("primary") and f.get("type") == "Model":
primary_file = f
break
if not primary_file:
for f in files:
if isinstance(f, dict) and f.get("primary"):
primary_file = f
break
if not primary_file:
for f in files:
if isinstance(f, dict) and f.get("type") == "Model":
primary_file = f
break
if not primary_file and files:
first = files[0]
if isinstance(first, dict):
primary_file = first
model_block = as_dict(data.get("model"))
result = {
"filename": str(primary_file.get("name") or "").strip() if primary_file else "",
"model_name": str(model_block.get("name") or "").strip(),
"version_name": str(data.get("name") or "").strip(),
"base_model": str(data.get("baseModel") or "").strip(),
"hashes": as_dict(primary_file.get("hashes")) if primary_file else {},
"download_url": str(primary_file.get("downloadUrl") or "").strip() if primary_file else "",
"trained_words": [
str(w).strip()
for w in as_list(data.get("trainedWords"))
if str(w).strip()
],
}
_CIVITAI_VERSION_FILES_CACHE[vid] = result
return result
def resolve_civitai_resource_filenames(
resources: list[dict[str, Any]],
api_key: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Enrich Civitai resources with real filenames and local availability."""
if not resources:
return resources
version_ids_to_fetch = set()
for res in resources:
vid = res.get("model_version_id") or res.get("modelVersionId")
if vid:
try:
version_ids_to_fetch.add(int(vid))
except (TypeError, ValueError):
pass
if not version_ids_to_fetch:
return resources
missing_vids = [
v for v in version_ids_to_fetch if v not in _CIVITAI_VERSION_FILES_CACHE
]
if missing_vids:
max_workers = min(len(missing_vids), 8)
try:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
list(
executor.map(
lambda v: get_civitai_version_file_info(v, api_key=api_key),
missing_vids,
)
)
except Exception as exc:
log.warning(f"Error resolving Civitai model version files: {exc}")
for res in resources:
vid = res.get("model_version_id") or res.get("modelVersionId")
if not vid:
continue
try:
info = _CIVITAI_VERSION_FILES_CACHE.get(int(vid))
except (TypeError, ValueError):
info = None
if not info:
continue
filename = info.get("filename")
model_name = info.get("model_name") or ""
version_name = info.get("version_name") or ""
if filename:
res["filename"] = filename
res["original_path"] = filename
original_display_name = (
res.get("name") or model_name or ""
)
if original_display_name.startswith("Civitai model ") and model_name:
original_display_name = model_name
res["name"] = model_name
if original_display_name:
res["model_name"] = original_display_name
res["display_name"] = original_display_name
if version_name and not res.get("version_name"):
res["version_name"] = version_name
if info.get("base_model"):
res["base_model"] = info["base_model"]
if info.get("download_url"):
res["download_url"] = info["download_url"]
if info.get("hashes"):
res["hashes"] = info["hashes"]
if info.get("trained_words"):
res["trained_words"] = info["trained_words"]
# Check local file existence in ComfyUI
target_filename = res.get("filename") or res.get("name")
if target_filename:
try:
from ..scanner import find_local_file_path
category = res.get("category")
local_path = find_local_file_path(target_filename, category)
if local_path:
res["exists"] = True
res["resolved_path"] = local_path
res["path"] = local_path
except Exception:
pass
return resources
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],
*,
workflow_format: Optional[str],
workflow_source: str,
) -> dict[str, Any]:
parameters = next(
(
value
for key, value in metadata.items()
if str(key).strip().lower() == "parameters"
),
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)
if isinstance(parameters, str):
parameters = parameters[:MAX_PARAMETERS_LENGTH]
else:
parameters = ""
return {
"keys": list(metadata.keys()),
"has_workflow": bool(workflow_format),
"workflow_source": workflow_source,
"parameters": parameters,
}
def inspect_image_bytes(
raw: bytes,
filename: str = "",
*,
api_key: Optional[str] = None,
) -> dict[str, Any]:
"""Inspect a supported image without writing it or executing its workflow."""
try:
from PIL import Image
with Image.open(io.BytesIO(raw)) as image:
image.load()
metadata = _image_metadata(image)
civitai_metadata, civitai_resources = _extract_civitai_metadata(metadata)
if civitai_resources:
civitai_resources = resolve_civitai_resource_filenames(
civitai_resources, api_key=api_key
)
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
)
return {
"source_type": "image",
"filename": filename or "Dropped image",
"image": {
"format": str(image.format or "").upper(),
"width": int(image.width),
"height": int(image.height),
"mode": str(image.mode or ""),
},
"metadata": _metadata_summary(
metadata,
workflow_format=workflow_format,
workflow_source=workflow_source,
),
"workflow": workflow,
"workflow_format": workflow_format or "",
"workflow_source": workflow_source,
"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
except Exception as exc:
raise ValueError(f"Could not read image metadata: {exc}") from exc
def _parse_workflow_json_text(value: str) -> Any:
if len(value.encode("utf-8")) > MAX_WORKFLOW_JSON_BYTES:
raise ValueError("Workflow JSON is too large")
try:
return json.loads(value)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError("Workflow JSON is not valid JSON") from exc
def inspect_workflow_json(
workflow: Any,
*,
filename: str = "",
source_type: str = "workflow",
) -> dict[str, Any]:
"""Normalize a UI/API workflow for the read-only inspector."""
if isinstance(workflow, str):
workflow = _parse_workflow_json_text(workflow)
try:
normalized, workflow_format = normalize_workflow_payload(workflow)
except ValueError:
raise
return {
"source_type": source_type,
"filename": filename or "Pasted workflow",
"image": None,
"metadata": {
"keys": [],
"has_workflow": True,
"workflow_source": "json",
"parameters": "",
},
"workflow": normalized,
"workflow_format": workflow_format,
"workflow_source": "json",
"workflow_node_count": len(normalized.get("nodes", [])),
"civitai_metadata": {},
"civitai_resources": [],
"civitai_workflow": "",
}
def inspect_civitai_image(
image_id: int,
*,
filename: str = "",
url: str = "",
api_key: Optional[str] = None,
) -> dict[str, Any]:
"""Fetch Civitai image page and extract full generation metadata and resources."""
headers = {"User-Agent": DEFAULT_BROWSER_USER_AGENT, "Accept": "text/html"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
response = request_source_response(
f"https://civitai.com/images/{image_id}",
headers=headers,
timeout=20,
log_name="CivitAI image page",
)
if response is None or response.status_code != 200:
response = request_source_response(
f"https://civitai.red/images/{image_id}",
headers=headers,
timeout=20,
log_name="CivitAI.red image page",
)
if response is None or response.status_code != 200:
status = response.status_code if response else "no response"
raise ValueError(f"Could not load Civitai image {image_id} ({status})")
html_text = response.text
match = re.search(
r'<script\b(?=[^>]*\bid=["\']__NEXT_DATA__["\'])[^>]*>(.*?)</script>',
html_text,
re.DOTALL | re.IGNORECASE,
)
if not match:
raise ValueError(f"Civitai page for image {image_id} did not contain generation data")
try:
data = as_dict(json.loads(match.group(1)))
except (TypeError, ValueError) as exc:
raise ValueError(f"Could not parse Civitai page data: {exc}") from exc
page = as_dict(as_dict(data.get("props")).get("pageProps"))
state = as_dict(page.get("trpcState"))
state = as_dict(state.get("json")) or state
queries = as_list(state.get("queries"))
img_data: dict[str, Any] = {}
gen_data: dict[str, Any] = {}
for query in queries:
query_dict = as_dict(query)
key = as_list(query_dict.get("queryKey"))
if not key:
continue
first_key = key[0]
if first_key == ["image", "get"]:
img_data = as_dict(as_dict(query_dict.get("state")).get("data"))
elif first_key == ["image", "getGenerationData"]:
gen_data = as_dict(as_dict(query_dict.get("state")).get("data"))
meta = as_dict(gen_data.get("meta"))
raw_resources = as_list(gen_data.get("resources"))
meta_resources = as_list(meta.get("civitaiResources"))
all_resources = list(raw_resources) + list(meta_resources)
civitai_resources = _merge_civitai_resources(all_resources)
civitai_resources = resolve_civitai_resource_filenames(
civitai_resources, api_key=api_key
)
# Check for embedded ComfyUI workflow
workflow = None
workflow_format = ""
workflow_source = ""
comfy_candidate = meta.get("comfy") or as_dict(meta.get("extra")).get("comfy")
if isinstance(comfy_candidate, str):
try:
workflow = json.loads(comfy_candidate)
except Exception:
workflow = None
elif isinstance(comfy_candidate, Mapping):
workflow = dict(comfy_candidate)
if isinstance(workflow, Mapping):
try:
workflow, workflow_format = normalize_workflow_payload(workflow)
workflow_source = "Civitai generation data"
except Exception:
workflow = None
prompt = str(meta.get("prompt") or "").strip()
negative_prompt = str(meta.get("negativePrompt") or "").strip()
# Normalize LoRA references in prompt if titles or version IDs were used
for res in civitai_resources:
if res.get("category") == "loras" and res.get("filename"):
clean_name = os.path.splitext(res["filename"])[0]
names_to_replace = []
if (
res.get("model_name")
and res["model_name"].lower() != clean_name.lower()
):
names_to_replace.append(res["model_name"])
if res.get("model_id"):
names_to_replace.append(str(res["model_id"]))
if res.get("model_version_id"):
names_to_replace.append(str(res["model_version_id"]))
for name_pattern in names_to_replace:
pattern = re.compile(
rf"<lora:{re.escape(name_pattern)}(:[0-9\.\-]+(?:,[0-9\.\-]+)?)>",
re.IGNORECASE,
)
prompt = pattern.sub(rf"<lora:{clean_name}\1>", prompt)
meta["prompt"] = prompt
param_lines = []
if prompt:
param_lines.append(prompt)
if negative_prompt:
param_lines.append(f"Negative prompt: {negative_prompt}")
meta_details = []
if meta.get("steps") is not None:
meta_details.append(f"Steps: {meta['steps']}")
if meta.get("sampler"):
meta_details.append(f"Sampler: {meta['sampler']}")
if meta.get("cfgScale") is not None:
meta_details.append(f"CFG scale: {meta['cfgScale']}")
if meta.get("seed") is not None:
meta_details.append(f"Seed: {meta['seed']}")
width = img_data.get("width") or meta.get("width")
height = img_data.get("height") or meta.get("height")
if width and height:
meta_details.append(f"Size: {width}x{height}")
for res in civitai_resources:
if res.get("category") == "checkpoints":
ckpt_name = res.get("filename") or res.get("name")
if ckpt_name:
meta_details.append(f"Model: {ckpt_name}")
break
if meta_details:
param_lines.append(", ".join(meta_details))
parameters_text = "\n".join(param_lines)
display_name = (
filename
or str(img_data.get("name") or "")
or (url if url else f"Civitai image {image_id}")
)
mime_type = str(img_data.get("mimeType") or "image/jpeg")
img_format = "JPEG"
if "png" in mime_type.lower():
img_format = "PNG"
elif "webp" in mime_type.lower():
img_format = "WEBP"
civitai_workflow = str(meta.get("workflow") or gen_data.get("type") or "").strip()
metadata_dict = {
"keys": ["Civitai metadata", "Civitai resources"],
"has_workflow": bool(workflow_format),
"workflow_source": workflow_source or f"Civitai image {image_id}",
"parameters": parameters_text,
}
return {
"source_type": "civitai",
"civitai_image_id": image_id,
"filename": display_name,
"image": {
"format": img_format,
"width": int(width or 0),
"height": int(height or 0),
"mode": "RGB",
},
"metadata": metadata_dict,
"workflow": workflow,
"workflow_format": workflow_format,
"workflow_source": workflow_source or f"Civitai image {image_id}",
"workflow_node_count": len(workflow.get("nodes", [])) if isinstance(workflow, Mapping) else 0,
"civitai_metadata": meta,
"civitai_resources": civitai_resources,
"civitai_workflow": civitai_workflow,
}
class ImageMetadataService:
"""HTTP service for dropped images, workflow JSON files, and pasted JSON."""
def __init__(self, context: RouteContext):
self.asyncio = context.require("asyncio")
self.web = context.require("web")
async def inspect_metadata(self, request):
data = await read_optional_object_payload(request)
try:
source_type = read_text_field(
data,
"source_type",
contract_name="Metadata inspection request",
).lower()
filename = read_optional_text_field(
data,
"filename",
contract_name="Metadata inspection request",
) or ""
civitai_key = read_optional_text_field(
data,
"civitai_key",
contract_name="Metadata inspection request",
) or ""
except TypeError as exc:
return self.web.json_response({"error": str(exc)}, status=400)
try:
if source_type == "image":
data_url = data.get("data_url")
if not isinstance(data_url, str):
raise ValueError("Image data is required")
raw = _read_image_bytes(data_url)
result = await self.asyncio.to_thread(
inspect_image_bytes,
raw,
filename,
api_key=civitai_key or None,
)
elif source_type in {"workflow", "json"}:
from ..sources.civitai import parse_civitai_image_url
workflow = data.get("workflow")
if workflow is None:
workflow = data.get("json_text")
if isinstance(workflow, str):
civitai_id = parse_civitai_image_url(workflow.strip())
if civitai_id:
result = await self.asyncio.to_thread(
inspect_civitai_image,
civitai_id,
filename=filename,
url=workflow.strip(),
api_key=civitai_key or None,
)
return self.web.json_response(result)
if workflow is None:
raise ValueError("Workflow JSON is required")
result = await self.asyncio.to_thread(
inspect_workflow_json,
workflow,
filename=filename,
source_type="workflow",
)
elif source_type in {"civitai", "civitai_image"}:
from ..sources.civitai import parse_civitai_image_url
image_id = data.get("image_id")
url_candidate = str(data.get("url") or "")
if not image_id and url_candidate:
image_id = parse_civitai_image_url(url_candidate)
if not image_id:
raise ValueError("Civitai image ID or URL is required")
result = await self.asyncio.to_thread(
inspect_civitai_image,
int(image_id),
filename=filename,
url=url_candidate,
api_key=civitai_key or None,
)
else:
raise ValueError("Metadata source type must be image, workflow, or civitai")
except (TypeError, ValueError) as exc:
return self.web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
return self.web.json_response(
{"error": f"Metadata inspection failed: {exc}"},
status=500,
)
return self.web.json_response(result)