feat: isolate multi-category transfer targets, add node locator, and expand image metadata formats
This commit is contained in:
@@ -29,7 +29,7 @@ from ..workflow.formats import normalize_workflow_payload
|
||||
|
||||
log = create_module_logger(__name__)
|
||||
|
||||
MAX_IMAGE_BYTES = 64 * 1024 * 1024
|
||||
MAX_IMAGE_BYTES = 256 * 1024 * 1024
|
||||
MAX_WORKFLOW_JSON_BYTES = 16 * 1024 * 1024
|
||||
MAX_PARAMETERS_LENGTH = 12_000
|
||||
EXIF_IFD_TAG = 34665
|
||||
@@ -78,15 +78,31 @@ def _metadata_json_value(value: Any) -> Any:
|
||||
if not isinstance(current, str):
|
||||
return current
|
||||
text = current.strip()
|
||||
if not text:
|
||||
return current
|
||||
if text.startswith(";"):
|
||||
lines = [
|
||||
line.strip()
|
||||
for line in text.splitlines()
|
||||
if line.strip() and not line.strip().startswith(";")
|
||||
]
|
||||
text = "\n".join(lines).strip()
|
||||
if not text:
|
||||
return current
|
||||
if text[0] not in "[{\"":
|
||||
prefix, separator, remainder = text.partition(":")
|
||||
prefix, separator, remainder = "", "", ""
|
||||
for candidate_sep in (":", "="):
|
||||
p, s, r = text.partition(candidate_sep)
|
||||
if s and (not separator or len(p) < len(prefix)):
|
||||
prefix, separator, remainder = p, s, r
|
||||
if separator and prefix.strip().lower() in {
|
||||
"prompt",
|
||||
"workflow",
|
||||
"comfy",
|
||||
"comfyui",
|
||||
"comment",
|
||||
"description",
|
||||
"usercomment",
|
||||
}:
|
||||
text = remainder.strip()
|
||||
else:
|
||||
@@ -96,7 +112,16 @@ def _metadata_json_value(value: Any) -> Any:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return current
|
||||
unescaped = (
|
||||
text.replace(r"\=", "=")
|
||||
.replace(r"\;", ";")
|
||||
.replace(r"\#", "#")
|
||||
.replace(r"\\", "\\")
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(unescaped)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return current
|
||||
if not isinstance(parsed, str):
|
||||
return parsed
|
||||
current = parsed
|
||||
@@ -149,20 +174,20 @@ def _read_image_bytes(data_url: str) -> bytes:
|
||||
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")
|
||||
raise ValueError("Media payload must be a base64 data URL")
|
||||
encoded = "".join(encoded.split())
|
||||
if not encoded:
|
||||
raise ValueError("Image payload is empty")
|
||||
raise ValueError("Media payload is empty")
|
||||
if len(encoded) > ((MAX_IMAGE_BYTES + 2) * 4 // 3) + 16:
|
||||
raise ValueError("Image payload is too large")
|
||||
raise ValueError("Media 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
|
||||
raise ValueError("Media payload is not valid base64") from exc
|
||||
if not raw:
|
||||
raise ValueError("Image payload is empty")
|
||||
raise ValueError("Media payload is empty")
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("Image payload is too large")
|
||||
raise ValueError("Media payload is too large")
|
||||
return raw
|
||||
|
||||
|
||||
@@ -209,13 +234,18 @@ def _find_embedded_workflow(metadata: Mapping[str, Any]):
|
||||
"comfy",
|
||||
"extra_pnginfo",
|
||||
"extra_metadata",
|
||||
"comment",
|
||||
"description",
|
||||
"usercomment",
|
||||
"graph",
|
||||
)
|
||||
candidates = []
|
||||
seen = set()
|
||||
for key in priority_keys:
|
||||
if key in metadata:
|
||||
candidates.append((key, metadata[key]))
|
||||
seen.add(key)
|
||||
for priority_key in priority_keys:
|
||||
for key, value in metadata.items():
|
||||
if str(key).strip().lower() == priority_key and key not in seen:
|
||||
candidates.append((key, value))
|
||||
seen.add(key)
|
||||
candidates.extend(
|
||||
(key, value)
|
||||
for key, value in metadata.items()
|
||||
@@ -228,7 +258,7 @@ def _find_embedded_workflow(metadata: Mapping[str, Any]):
|
||||
normalized, workflow_format = normalize_workflow_payload(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
return normalized, workflow_format, key
|
||||
return normalized, workflow_format, str(key)
|
||||
return None, None, ""
|
||||
|
||||
|
||||
@@ -575,13 +605,243 @@ def _metadata_summary(
|
||||
}
|
||||
|
||||
|
||||
def _extract_raw_container_metadata(raw: bytes) -> dict[str, Any]:
|
||||
"""Scan container bytes for embedded ComfyUI workflow JSON payloads."""
|
||||
extracted: dict[str, Any] = {}
|
||||
if not raw or len(raw) < 16:
|
||||
return extracted
|
||||
|
||||
# 1. Search for Matroska/WebM EBML COMMENT tag
|
||||
if raw.startswith(b"\x1aE\xdf\xa3"):
|
||||
comment_idx = raw.find(b"COMMENT")
|
||||
while comment_idx != -1 and comment_idx < len(raw) - 16:
|
||||
tag_offset = comment_idx + 7
|
||||
if tag_offset + 2 <= len(raw) and raw[tag_offset : tag_offset + 2] == b"\x44\x87":
|
||||
vint_offset = tag_offset + 2
|
||||
if vint_offset + 4 <= len(raw):
|
||||
vint = int.from_bytes(raw[vint_offset : vint_offset + 4], "big")
|
||||
n_octets = (32 - vint.bit_length()) // 8 + 1 if vint else 1
|
||||
if n_octets <= 4:
|
||||
shift = 8 * (4 - n_octets)
|
||||
length = (vint >> shift) & ~(1 << (7 * n_octets))
|
||||
data_start = vint_offset + n_octets
|
||||
data_end = data_start + length
|
||||
if data_end <= len(raw):
|
||||
try:
|
||||
payload = raw[data_start:data_end].decode("utf-8", errors="replace")
|
||||
extracted["comment"] = payload
|
||||
except Exception:
|
||||
pass
|
||||
comment_idx = raw.find(b"COMMENT", comment_idx + 1)
|
||||
|
||||
# 2. General search for workflow JSON fragments: "nodes": or "class_type":
|
||||
markers = (b'"nodes":', b'"class_type":', b'"workflow":', b'"prompt":')
|
||||
for marker in markers:
|
||||
if "workflow" in extracted or "prompt" in extracted:
|
||||
break
|
||||
pos = raw.find(marker)
|
||||
scan_limit = 0
|
||||
while pos != -1 and scan_limit < 3:
|
||||
scan_limit += 1
|
||||
start = raw.rfind(b"{", max(0, pos - 200), pos)
|
||||
if start != -1:
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
end = -1
|
||||
max_scan = min(start + 16 * 1024 * 1024, len(raw))
|
||||
for i in range(start, max_scan):
|
||||
b = raw[i : i + 1]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif b == b"\\":
|
||||
escape = True
|
||||
elif b == b'"':
|
||||
in_string = False
|
||||
else:
|
||||
if b == b'"':
|
||||
in_string = True
|
||||
elif b == b"{":
|
||||
depth += 1
|
||||
elif b == b"}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
if end != -1:
|
||||
try:
|
||||
candidate_text = raw[start:end].decode("utf-8", errors="replace")
|
||||
parsed = json.loads(candidate_text)
|
||||
if isinstance(parsed, Mapping):
|
||||
normalized, _fmt = normalize_workflow_payload(parsed)
|
||||
if normalized:
|
||||
extracted["workflow"] = candidate_text
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
pos = raw.find(marker, pos + 1)
|
||||
|
||||
return extracted
|
||||
|
||||
|
||||
def inspect_container_bytes(
|
||||
raw: bytes,
|
||||
filename: str = "",
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Inspect a video/audio container without writing it or executing its workflow."""
|
||||
metadata: dict[str, Any] = {}
|
||||
width = 0
|
||||
height = 0
|
||||
mode = ""
|
||||
duration = None
|
||||
media_type = "media"
|
||||
format_name = ""
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower() if filename else ""
|
||||
|
||||
try:
|
||||
import av
|
||||
|
||||
with av.open(io.BytesIO(raw)) as container:
|
||||
if container.metadata:
|
||||
for k, v in container.metadata.items():
|
||||
metadata[str(k)] = _safe_metadata_value(v, tag_name=str(k))
|
||||
|
||||
for stream in container.streams:
|
||||
if stream.metadata:
|
||||
for k, v in stream.metadata.items():
|
||||
metadata.setdefault(
|
||||
f"{stream.type}:{k}",
|
||||
_safe_metadata_value(v, tag_name=str(k)),
|
||||
)
|
||||
metadata.setdefault(
|
||||
str(k),
|
||||
_safe_metadata_value(v, tag_name=str(k)),
|
||||
)
|
||||
|
||||
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
||||
audio_stream = next((s for s in container.streams if s.type == "audio"), None)
|
||||
|
||||
if video_stream is not None:
|
||||
media_type = "video"
|
||||
elif audio_stream is not None:
|
||||
media_type = "audio"
|
||||
|
||||
raw_format = (container.format.name or media_type).upper().split(",")[0].strip()
|
||||
if "MATROSKA" in raw_format:
|
||||
format_name = "WEBM" if ext == ".webm" else "MKV"
|
||||
elif "MOV" in raw_format or "MP4" in raw_format:
|
||||
format_name = "MP4" if ext in {".mp4", ".m4v"} else "MOV"
|
||||
else:
|
||||
format_name = raw_format
|
||||
|
||||
if container.duration is not None and getattr(av, "time_base", None):
|
||||
try:
|
||||
duration = round(float(container.duration / av.time_base), 2)
|
||||
except Exception:
|
||||
duration = None
|
||||
|
||||
if video_stream is not None:
|
||||
width = int(video_stream.width or 0)
|
||||
height = int(video_stream.height or 0)
|
||||
pix_fmt = getattr(video_stream.format, "name", "") if video_stream.format else ""
|
||||
codec = getattr(video_stream.codec_context, "name", "") if getattr(video_stream, "codec_context", None) else ""
|
||||
mode = f"{codec} ({pix_fmt})" if codec and pix_fmt else (pix_fmt or codec or "video")
|
||||
elif audio_stream is not None:
|
||||
sample_rate = getattr(audio_stream, "rate", 0) or 0
|
||||
channels = getattr(audio_stream, "channels", 0) or 0
|
||||
codec = getattr(audio_stream.codec_context, "name", "") if getattr(audio_stream, "codec_context", None) else ""
|
||||
mode = f"{codec} ({channels} ch, {sample_rate} Hz)" if codec else f"{channels} ch, {sample_rate} Hz"
|
||||
except Exception as av_exc:
|
||||
log.debug(f"PyAV container extraction failed for {filename}: {av_exc}")
|
||||
|
||||
if not any(str(k).strip().lower() in ("workflow", "prompt") for k in metadata):
|
||||
raw_metadata = _extract_raw_container_metadata(raw)
|
||||
for k, v in raw_metadata.items():
|
||||
metadata.setdefault(k, v)
|
||||
|
||||
if not format_name:
|
||||
if ext in {".mp4", ".m4v"}:
|
||||
format_name = "MP4"
|
||||
media_type = "video"
|
||||
elif ext == ".webm":
|
||||
format_name = "WEBM"
|
||||
media_type = "video"
|
||||
elif ext == ".mkv":
|
||||
format_name = "MKV"
|
||||
media_type = "video"
|
||||
elif ext == ".mov":
|
||||
format_name = "MOV"
|
||||
media_type = "video"
|
||||
elif ext == ".flac":
|
||||
format_name = "FLAC"
|
||||
media_type = "audio"
|
||||
elif ext in {".mp3", ".wav", ".ogg", ".opus"}:
|
||||
format_name = ext.lstrip(".").upper()
|
||||
media_type = "audio"
|
||||
else:
|
||||
format_name = "MEDIA"
|
||||
|
||||
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": media_type,
|
||||
"filename": filename or f"Dropped {media_type}",
|
||||
"image": {
|
||||
"format": format_name,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mode": mode,
|
||||
"duration": duration,
|
||||
"media_type": media_type,
|
||||
},
|
||||
"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 ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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."""
|
||||
"""Inspect a supported image or media file without writing it or executing its workflow."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
@@ -634,10 +894,14 @@ def inspect_image_bytes(
|
||||
else ""
|
||||
),
|
||||
}
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Could not read image metadata: {exc}") from exc
|
||||
except Exception as img_exc:
|
||||
try:
|
||||
return inspect_container_bytes(raw, filename=filename, api_key=api_key)
|
||||
except Exception as container_exc:
|
||||
raise ValueError(f"Could not read metadata: {container_exc or img_exc}") from container_exc
|
||||
|
||||
|
||||
inspect_media_bytes = inspect_image_bytes
|
||||
|
||||
|
||||
def _parse_workflow_json_text(value: str) -> Any:
|
||||
@@ -921,7 +1185,7 @@ class ImageMetadataService:
|
||||
return self.web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
try:
|
||||
if source_type == "image":
|
||||
if source_type in {"image", "media", "video", "audio"}:
|
||||
data_url = data.get("data_url")
|
||||
if not isinstance(data_url, str):
|
||||
raise ValueError("Image data is required")
|
||||
@@ -974,7 +1238,7 @@ class ImageMetadataService:
|
||||
api_key=civitai_key or None,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Metadata source type must be image, workflow, or civitai")
|
||||
raise ValueError("Metadata source type must be image, media, video, audio, workflow, or civitai")
|
||||
except (TypeError, ValueError) as exc:
|
||||
return self.web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -14,6 +14,8 @@ _WORKFLOW_CONTAINER_KEYS = (
|
||||
"extra_pnginfo",
|
||||
"extra_metadata",
|
||||
"graph",
|
||||
"comment",
|
||||
"description",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+3562
-3478
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,48 @@ def jpeg_with_nested_user_comment(comment):
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def mp4_with_metadata(metadata):
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
buf = io.BytesIO()
|
||||
with av.open(buf, mode="w", format="mp4", options={"movflags": "use_metadata_tags"}) as c:
|
||||
for k, v in metadata.items():
|
||||
c.metadata[k] = v if isinstance(v, str) else json.dumps(v)
|
||||
s = c.add_stream("h264", rate=24)
|
||||
s.width = 64
|
||||
s.height = 64
|
||||
s.pix_fmt = "yuv420p"
|
||||
f = av.VideoFrame.from_ndarray(np.zeros((64, 64, 3), dtype=np.uint8), format="rgb24")
|
||||
f.pts = 0
|
||||
for p in s.encode(f):
|
||||
c.mux(p)
|
||||
for p in s.encode(None):
|
||||
c.mux(p)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def webm_with_metadata(metadata):
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
buf = io.BytesIO()
|
||||
with av.open(buf, mode="w", format="webm") as c:
|
||||
for k, v in metadata.items():
|
||||
c.metadata[k] = v if isinstance(v, str) else json.dumps(v)
|
||||
s = c.add_stream("vp9", rate=24)
|
||||
s.width = 64
|
||||
s.height = 64
|
||||
s.pix_fmt = "yuv420p"
|
||||
f = av.VideoFrame.from_ndarray(np.zeros((64, 64, 3), dtype=np.uint8), format="rgb24")
|
||||
f.pts = 0
|
||||
for p in s.encode(f):
|
||||
c.mux(p)
|
||||
for p in s.encode(None):
|
||||
c.mux(p)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_api_workflow_is_detected_and_normalized_for_existing_analyzer():
|
||||
api = api_workflow()
|
||||
|
||||
@@ -152,7 +194,11 @@ def test_jpeg_nested_exif_reads_civitai_metadata_and_resources():
|
||||
f"Civitai metadata: {json.dumps(civitai_metadata)}"
|
||||
)
|
||||
|
||||
result = inspect_image_bytes(jpeg_with_nested_user_comment(comment), "civitai.jpg")
|
||||
with patch(
|
||||
"core.services.image_metadata_service.execute_provider_json_request",
|
||||
return_value=None,
|
||||
):
|
||||
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"]
|
||||
@@ -228,6 +274,105 @@ async def test_metadata_service_accepts_image_data_url():
|
||||
assert body["workflow_node_count"] == 2
|
||||
|
||||
|
||||
def test_mp4_metadata_reads_comfyui_workflow():
|
||||
workflow_payload = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"widgets_values": ["model.safetensors"],
|
||||
}
|
||||
]
|
||||
}
|
||||
mp4_bytes = mp4_with_metadata({"workflow": json.dumps(workflow_payload)})
|
||||
result = inspect_image_bytes(mp4_bytes, "animation.mp4")
|
||||
|
||||
assert result["source_type"] == "video"
|
||||
assert result["image"]["format"] == "MP4"
|
||||
assert result["image"]["width"] == 64
|
||||
assert result["image"]["height"] == 64
|
||||
assert result["workflow_format"] == UI_WORKFLOW_FORMAT
|
||||
assert result["workflow_node_count"] == 1
|
||||
assert result["metadata"]["has_workflow"] is True
|
||||
|
||||
|
||||
def test_webm_metadata_reads_comfyui_workflow():
|
||||
workflow_payload = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"widgets_values": [123, 20],
|
||||
}
|
||||
]
|
||||
}
|
||||
webm_bytes = webm_with_metadata({"WORKFLOW": json.dumps(workflow_payload)})
|
||||
result = inspect_image_bytes(webm_bytes, "clip.webm")
|
||||
|
||||
assert result["source_type"] == "video"
|
||||
assert result["image"]["format"] == "WEBM"
|
||||
assert result["workflow_format"] == UI_WORKFLOW_FORMAT
|
||||
assert result["workflow_node_count"] == 1
|
||||
|
||||
|
||||
def test_media_raw_byte_fallback_extracts_workflow():
|
||||
workflow_payload = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "VAELoader",
|
||||
"widgets_values": ["vae.safetensors"],
|
||||
}
|
||||
]
|
||||
}
|
||||
raw = (
|
||||
b"some_random_mp4_box_bytes"
|
||||
+ json.dumps(workflow_payload).encode("utf-8")
|
||||
+ b"trailing_raw_container_data"
|
||||
)
|
||||
result = inspect_image_bytes(raw, "raw_video.mp4")
|
||||
|
||||
assert result["image"]["format"] == "MP4"
|
||||
assert result["workflow_format"] == UI_WORKFLOW_FORMAT
|
||||
assert result["workflow_node_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_service_accepts_video_source_type():
|
||||
workflow_payload = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"widgets_values": ["model.safetensors"],
|
||||
}
|
||||
]
|
||||
}
|
||||
raw = mp4_with_metadata({"workflow": json.dumps(workflow_payload)})
|
||||
data_url = "data:video/mp4;base64," + base64.b64encode(raw).decode("ascii")
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"source_type": "video",
|
||||
"filename": "render.mp4",
|
||||
"data_url": data_url,
|
||||
}
|
||||
)
|
||||
)
|
||||
service = ImageMetadataService(
|
||||
RouteContext({"asyncio": asyncio, "web": web})
|
||||
)
|
||||
|
||||
response = await service.inspect_metadata(request)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert body["source_type"] == "video"
|
||||
assert body["image"]["format"] == "MP4"
|
||||
assert body["workflow_format"] == UI_WORKFLOW_FORMAT
|
||||
assert body["workflow_node_count"] == 1
|
||||
|
||||
|
||||
def civitai_html_payload(meta=None, resources=None, img_data=None):
|
||||
if meta is None:
|
||||
meta = {
|
||||
|
||||
@@ -742,3 +742,205 @@ test('workflow node context keeps Locate Node but hides model info', () => {
|
||||
assert.equal(visibility.showInfo, false);
|
||||
assert.equal(visibility.locateNode, true);
|
||||
});
|
||||
|
||||
test('multi-category target node keeps selections, previews, and transfer targets isolated per category', async () => {
|
||||
const context = createTransferContext();
|
||||
const loadedModels = {
|
||||
loaded_models: [
|
||||
{
|
||||
category: 'upscale_models',
|
||||
node_id: 1512,
|
||||
node_type: 'Settings',
|
||||
widget_index: 0,
|
||||
original_path: '2x-AnimeSharpV4_RCAN.pth',
|
||||
active: true,
|
||||
connected: true,
|
||||
is_top_level: true,
|
||||
},
|
||||
{
|
||||
category: 'diffusion_models',
|
||||
node_id: 1512,
|
||||
node_type: 'Settings',
|
||||
widget_index: 1,
|
||||
original_path: 'DasimaMinimaxH3_slot1.safetensors',
|
||||
active: true,
|
||||
connected: true,
|
||||
is_top_level: true,
|
||||
},
|
||||
{
|
||||
category: 'diffusion_models',
|
||||
node_id: 1512,
|
||||
node_type: 'Settings',
|
||||
widget_index: 2,
|
||||
original_path: 'DasimaMinimaxH3_slot2.safetensors',
|
||||
active: true,
|
||||
connected: true,
|
||||
is_top_level: true,
|
||||
},
|
||||
{
|
||||
category: 'text_encoders',
|
||||
node_id: 1512,
|
||||
node_type: 'Settings',
|
||||
widget_index: 3,
|
||||
original_path: 'qwen3vl_32b.safetensors',
|
||||
active: true,
|
||||
connected: true,
|
||||
is_top_level: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const targetGroups = context.getMetadataTransferTargetGroups(loadedModels);
|
||||
assert.equal(targetGroups.length, 3);
|
||||
const upscaleGroup = targetGroups.find(g => g.category === 'upscale_models');
|
||||
const diffusionGroup = targetGroups.find(g => g.category === 'diffusion_models');
|
||||
const textEncGroup = targetGroups.find(g => g.category === 'text_encoders');
|
||||
assert.ok(upscaleGroup && diffusionGroup && textEncGroup);
|
||||
assert.equal(upscaleGroup.nodes[0].targetKey, 'upscale_models:top:1512');
|
||||
assert.equal(diffusionGroup.nodes[0].targetKey, 'diffusion_models:top:1512');
|
||||
assert.equal(textEncGroup.nodes[0].targetKey, 'text_encoders:top:1512');
|
||||
assert.equal(upscaleGroup.nodes[0].refs.length, 1);
|
||||
assert.equal(diffusionGroup.nodes[0].refs.length, 2);
|
||||
assert.equal(textEncGroup.nodes[0].refs.length, 1);
|
||||
|
||||
const state = {
|
||||
loadedModels: {
|
||||
loaded_models: [
|
||||
{ category: 'upscale_models', original_path: 'imported-upscale.pth' },
|
||||
{ category: 'diffusion_models', original_path: 'imported-diffusion.safetensors' },
|
||||
{ category: 'text_encoders', original_path: 'imported-clip.safetensors' },
|
||||
],
|
||||
},
|
||||
transfer: {
|
||||
mode: 'replace',
|
||||
activityScope: 'all',
|
||||
targetGroups,
|
||||
selectedNodeKeys: new Set(),
|
||||
sourceSelections: {},
|
||||
},
|
||||
};
|
||||
context.imageInspectorState = state;
|
||||
context.renderMetadataTransferPanelInPlace = () => true;
|
||||
|
||||
// Initially none selected
|
||||
let html = context.renderMetadataTransferPanel(state);
|
||||
assert.match(html, /<button[^>]*data-image-transfer-target="upscale_models:top:1512"[^>]*aria-pressed="false"/);
|
||||
assert.match(html, /<button[^>]*data-image-transfer-target="diffusion_models:top:1512"[^>]*aria-pressed="false"/);
|
||||
|
||||
// Toggle ONLY upscale_models
|
||||
context.toggleMetadataTransferNode('upscale_models:top:1512');
|
||||
assert.deepEqual(Array.from(state.transfer.selectedNodeKeys), ['upscale_models:top:1512']);
|
||||
|
||||
// Preview rows should contain ONLY upscale models
|
||||
const previewRows = context.getMetadataTransferPreviewRows(state);
|
||||
assert.equal(previewRows.length, 1);
|
||||
assert.equal(previewRows[0].category, 'upscale_models');
|
||||
assert.equal(previewRows[0].targetKey, 'upscale_models:top:1512');
|
||||
assert.equal(previewRows[0].currentValue, '2x-AnimeSharpV4_RCAN.pth');
|
||||
|
||||
// Rendered panel should mark upscale as selected and diffusion as unselected
|
||||
html = context.renderMetadataTransferPanel(state);
|
||||
assert.match(html, /<button[^>]*data-image-transfer-target="upscale_models:top:1512"[^>]*aria-pressed="true"/);
|
||||
assert.match(html, /<button[^>]*data-image-transfer-target="diffusion_models:top:1512"[^>]*aria-pressed="false"/);
|
||||
assert.match(html, /<button[^>]*data-image-transfer-target="text_encoders:top:1512"[^>]*aria-pressed="false"/);
|
||||
|
||||
// In the upscale node preview, diffusion model strings must NOT appear
|
||||
const upscaleSection = html.slice(
|
||||
html.indexOf('data-image-transfer-category="upscale_models"'),
|
||||
html.indexOf('data-image-transfer-category="diffusion_models"')
|
||||
);
|
||||
assert.match(upscaleSection, /2x-AnimeSharpV4_RCAN\.pth/);
|
||||
assert.doesNotMatch(upscaleSection, /DasimaMinimaxH3/);
|
||||
assert.doesNotMatch(upscaleSection, /qwen3vl_32b/);
|
||||
|
||||
// Apply transfer should send ONLY the upscale target ref
|
||||
let appliedPayload = null;
|
||||
context.getCurrentWorkflow = () => ({ signature: 'wf-1512' });
|
||||
context.getWorkflowSignature = () => 'wf-1512';
|
||||
context.fetchJson = async (url, options) => {
|
||||
if (url.includes('transfer-models')) {
|
||||
appliedPayload = JSON.parse(options.body);
|
||||
return { success: true, updated: 1, workflow: { signature: 'wf-1512-updated' } };
|
||||
}
|
||||
return { loaded_models: [] };
|
||||
};
|
||||
context.updateWorkflowInComfyUI = async () => true;
|
||||
|
||||
await context.applyMetadataTransfer();
|
||||
assert.ok(appliedPayload);
|
||||
assert.equal(appliedPayload.target_refs.length, 1);
|
||||
assert.equal(appliedPayload.target_refs[0].category, 'upscale_models');
|
||||
assert.equal(appliedPayload.target_refs[0].node_id, 1512);
|
||||
assert.equal(appliedPayload.target_refs[0].widget_index, 0);
|
||||
});
|
||||
|
||||
test('metadata transfer renders locate button for target nodes', () => {
|
||||
const context = createTransferContext();
|
||||
const state = createTransferState([
|
||||
{ category: 'text_encoders', original_path: 'imported.safetensors' },
|
||||
]);
|
||||
|
||||
const html = context.renderMetadataTransferPanel(state);
|
||||
|
||||
assert.match(html, /class="[^"]*mr-image-transfer-node-target[^"]*"/);
|
||||
assert.match(html, /data-image-transfer-locate/);
|
||||
assert.match(html, /data-node-id="1"/);
|
||||
assert.match(html, /data-tooltip="Center this node in the ComfyUI graph\."/);
|
||||
assert.match(html, /aria-label="Center CLIPLoader in the ComfyUI graph"/);
|
||||
assert.match(html, /class="mr-node-chip-icon"/);
|
||||
});
|
||||
|
||||
test('metadata transfer locate button triggers locateNodeInGraph and does not toggle node selection', () => {
|
||||
const context = createTransferContext();
|
||||
let clickListener = null;
|
||||
const mockRoot = {
|
||||
dataset: {},
|
||||
addEventListener: (type, listener) => {
|
||||
if (type === 'click') clickListener = listener;
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
querySelector: sel => (sel === '.mr-image-inspector' ? mockRoot : null),
|
||||
};
|
||||
context.contentElement = mockRoot;
|
||||
|
||||
let locatedTarget = null;
|
||||
context.locateNodeInGraph = (nodeId, options) => {
|
||||
locatedTarget = { nodeId, ...options };
|
||||
};
|
||||
|
||||
let toggledNode = null;
|
||||
context.toggleMetadataTransferNode = (target) => {
|
||||
toggledNode = target;
|
||||
};
|
||||
|
||||
context.bindImageMetadataEvents();
|
||||
assert.ok(clickListener);
|
||||
|
||||
const locateButton = {
|
||||
dataset: {
|
||||
imageTransferLocate: '',
|
||||
nodeId: '1512',
|
||||
subgraphId: 'sub-42',
|
||||
isTopLevel: 'false',
|
||||
},
|
||||
};
|
||||
locateButton.closest = selector => (selector.includes('data-image-transfer-locate') ? locateButton : null);
|
||||
|
||||
let defaultPrevented = false;
|
||||
let propagationStopped = false;
|
||||
clickListener({
|
||||
target: locateButton,
|
||||
preventDefault: () => { defaultPrevented = true; },
|
||||
stopPropagation: () => { propagationStopped = true; },
|
||||
});
|
||||
|
||||
assert.equal(defaultPrevented, true);
|
||||
assert.equal(propagationStopped, true);
|
||||
assert.deepEqual(locatedTarget, {
|
||||
nodeId: 1512,
|
||||
subgraphId: 'sub-42',
|
||||
isTopLevel: false,
|
||||
});
|
||||
assert.equal(toggledNode, null);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { imageMetadataMethods, extractCivitaiImageId } from '../web/resolver/views/tabs_image_metadata_methods.js';
|
||||
import {
|
||||
imageMetadataMethods,
|
||||
extractCivitaiImageId,
|
||||
isMediaFile,
|
||||
isImageFile,
|
||||
isWorkflowFile,
|
||||
} from '../web/resolver/views/tabs_image_metadata_methods.js';
|
||||
import { tabsLoadedMethods } from '../web/resolver/views/tabs_loaded_methods.js';
|
||||
|
||||
globalThis.window = globalThis;
|
||||
@@ -41,7 +47,6 @@ test('image metadata sections default to expanded', () => {
|
||||
|
||||
test('toggling section marks it collapsed and persists state', () => {
|
||||
const ctx = createMockContext();
|
||||
const state = {};
|
||||
ctx.toggleImageInspectorSection('prompt-transfer');
|
||||
assert.strictEqual(ctx.isImageInspectorSectionCollapsed('prompt-transfer'), true);
|
||||
assert.strictEqual(ctx.isImageInspectorSectionCollapsed('model-transfer'), false);
|
||||
@@ -632,6 +637,61 @@ test('getMetadataTransferModelLabel appends friendly model name in brackets when
|
||||
assert.strictEqual(ctx.getMetadataTransferModelLabel(modelSameName), 'Cinematic Krea2');
|
||||
});
|
||||
|
||||
test('isMediaFile recognizes videos, audio and images', () => {
|
||||
assert.strictEqual(isMediaFile({ name: 'animation.mp4', type: 'video/mp4' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'clip.webm', type: 'video/webm' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'render.mkv', type: 'video/x-matroska' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'audio.flac', type: 'audio/flac' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'song.mp3', type: 'audio/mpeg' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'photo.png', type: 'image/png' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'photo.webp', type: 'image/webp' }), true);
|
||||
assert.strictEqual(isMediaFile({ name: 'unknown.xyz', type: 'application/octet-stream' }), false);
|
||||
assert.strictEqual(isMediaFile(null), false);
|
||||
|
||||
// isImageFile delegates to isMediaFile
|
||||
assert.strictEqual(isImageFile({ name: 'video.mp4' }), true);
|
||||
assert.strictEqual(isWorkflowFile({ name: 'workflow.json' }), true);
|
||||
});
|
||||
|
||||
test('inspectImageMetadataFile routes video files to inspectImageFile', async () => {
|
||||
const ctx = createMockContext();
|
||||
let inspectedFile = null;
|
||||
ctx.inspectImageFile = (file) => {
|
||||
inspectedFile = file;
|
||||
return { success: true };
|
||||
};
|
||||
const mp4File = { name: 'comfy.mp4', type: 'video/mp4', size: 1024 };
|
||||
const res = await ctx.inspectImageMetadataFile(mp4File);
|
||||
assert.strictEqual(res?.success, true);
|
||||
assert.strictEqual(inspectedFile?.name, 'comfy.mp4');
|
||||
});
|
||||
|
||||
test('handleImageMetadataPaste accepts video and audio data URLs', () => {
|
||||
const ctx = createMockContext();
|
||||
const { root } = createMockPasteTarget(ctx);
|
||||
let payload = null;
|
||||
ctx.runImageMetadataInspection = (p) => { payload = p; };
|
||||
let defaultPrevented = false;
|
||||
let propagationStopped = false;
|
||||
const event = {
|
||||
target: root,
|
||||
preventDefault: () => { defaultPrevented = true; },
|
||||
stopPropagation: () => { propagationStopped = true; },
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [],
|
||||
getData: () => 'data:video/mp4;base64,AAAA',
|
||||
},
|
||||
};
|
||||
assert.strictEqual(ctx.handleImageMetadataPaste(event), true);
|
||||
assert.strictEqual(defaultPrevented, true);
|
||||
assert.strictEqual(propagationStopped, true);
|
||||
assert.strictEqual(payload?.source_type, 'image');
|
||||
assert.strictEqual(payload?.filename, 'Pasted media');
|
||||
assert.strictEqual(payload?.data_url, 'data:video/mp4;base64,AAAA');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6078,12 +6078,22 @@ button.mr-info-link:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mr-image-transfer-node-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mr-spacing-8);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mr-image-transfer-node-select {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mr-spacing-10);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -6095,6 +6105,18 @@ button.mr-info-link:disabled {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-image-transfer-node-locate {
|
||||
padding: var(--mr-spacing-2) var(--mr-spacing-6);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
transition: var(--mr-missing-control-transition, background 0.15s ease, border-color 0.15s ease, color 0.15s ease), opacity 0.15s ease;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-image-transfer-node-locate:hover,
|
||||
#model-resolver-modal .mr-image-transfer-node-locate:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mr-image-transfer-node-select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
const WORKFLOW_CONTAINER_KEYS = new Set([
|
||||
'comfy',
|
||||
'comfyui',
|
||||
'comment',
|
||||
'description',
|
||||
'extra_metadata',
|
||||
'extra_pnginfo',
|
||||
'graph',
|
||||
'prompt',
|
||||
'workflow'
|
||||
]);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
updateWorkflowPromptValue,
|
||||
} from "../utils/prompt_utils.js";
|
||||
|
||||
const MAX_IMAGE_FILE_SIZE = 64 * 1024 * 1024;
|
||||
const MAX_IMAGE_FILE_SIZE = 256 * 1024 * 1024;
|
||||
const MAX_WORKFLOW_FILE_SIZE = 16 * 1024 * 1024;
|
||||
const METADATA_TRANSFER_MODE_STORAGE_KEY = 'model_resolver_metadata_transfer_mode';
|
||||
const METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY = 'model_resolver_metadata_transfer_activity_scope';
|
||||
@@ -110,13 +110,20 @@ function cloneWorkflowSnapshot(workflow) {
|
||||
}
|
||||
}
|
||||
|
||||
function isImageFile(file) {
|
||||
export function isMediaFile(file) {
|
||||
const name = String(file?.name || '').toLowerCase();
|
||||
return String(file?.type || '').startsWith('image/')
|
||||
|| /\.(png|jpe?g|webp)$/i.test(name);
|
||||
const type = String(file?.type || '').toLowerCase();
|
||||
return type.startsWith('image/')
|
||||
|| type.startsWith('video/')
|
||||
|| type.startsWith('audio/')
|
||||
|| /\.(png|jpe?g|webp|gif|tiff?|bmp|avif|mp4|webm|mkv|mov|avi|m4v|flac|mp3|wav|ogg|opus|m4a|aac)$/i.test(name);
|
||||
}
|
||||
|
||||
function isWorkflowFile(file) {
|
||||
export function isImageFile(file) {
|
||||
return isMediaFile(file);
|
||||
}
|
||||
|
||||
export function isWorkflowFile(file) {
|
||||
const name = String(file?.name || '').toLowerCase();
|
||||
return file?.type === 'application/json'
|
||||
|| file?.type === 'text/json'
|
||||
@@ -439,12 +446,12 @@ export const imageMetadataMethods = {
|
||||
<span class="mr-section-collapse-chevron" aria-hidden="true">${getSvgIcon('chevronDown')}</span>
|
||||
<span class="mr-loaded-models-title">Image Metadata</span>
|
||||
</button>
|
||||
${this.getMetadataTooltipBadge('Drop or paste (Ctrl+V) an image, Civitai link, or workflow JSON/text file here, or paste JSON into the box below.', 'Image metadata help')}
|
||||
${this.getMetadataTooltipBadge('Drop or paste (Ctrl+V) an image, video, audio, Civitai link, or workflow JSON/text file here, or paste JSON into the box below.', 'Metadata help')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-image-inspector-tools">
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,application/json,.json,text/plain,.txt" hidden data-image-inspector-input="file">
|
||||
<input type="file" accept="image/*,video/*,audio/*,application/json,.json,text/plain,.txt,.mp4,.webm,.mkv,.mov,.avi,.m4v,.flac,.mp3,.wav,.ogg,.opus" hidden data-image-inspector-input="file">
|
||||
<div class="mr-image-inspector-paste">
|
||||
<textarea id="mr-image-inspector-json-text" class="mr-image-inspector-textarea" rows="3" aria-label="Paste workflow JSON" placeholder="Paste a ComfyUI workflow JSON or Civitai image link (e.g. https://civitai.red/images/145110824)..."></textarea>
|
||||
<div class="mr-image-inspector-actions">
|
||||
@@ -562,9 +569,25 @@ export const imageMetadataMethods = {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeChip = event.target.closest?.('[data-image-transfer-node]');
|
||||
const locateBtn = event.target.closest?.('[data-image-transfer-locate]');
|
||||
if (locateBtn) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rawNodeId = locateBtn.dataset.nodeId;
|
||||
const numericNodeId = Number(rawNodeId);
|
||||
void this.locateNodeInGraph?.(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, {
|
||||
subgraphId: locateBtn.dataset.subgraphId || '',
|
||||
isTopLevel: locateBtn.dataset.isTopLevel !== 'false',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeChip = event.target.closest?.('[data-image-transfer-target], [data-image-transfer-node]');
|
||||
if (nodeChip) {
|
||||
this.toggleMetadataTransferNode(nodeChip.dataset.imageTransferNode);
|
||||
this.toggleMetadataTransferNode(
|
||||
nodeChip.dataset.imageTransferTarget || nodeChip.dataset.imageTransferNode,
|
||||
nodeChip.dataset.imageTransferCategory
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -779,12 +802,12 @@ export const imageMetadataMethods = {
|
||||
const text = clipboardData.getData?.('text/plain') || '';
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (/^data:image\/(png|jpeg|jpg|webp);base64,/i.test(trimmed)) {
|
||||
if (/^data:(image|video|audio)\/[a-zA-Z0-9.\-_+]+;base64,/i.test(trimmed)) {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
void this.runImageMetadataInspection({
|
||||
source_type: 'image',
|
||||
filename: 'Pasted image',
|
||||
filename: 'Pasted media',
|
||||
data_url: trimmed,
|
||||
});
|
||||
return true;
|
||||
@@ -816,30 +839,30 @@ export const imageMetadataMethods = {
|
||||
},
|
||||
|
||||
async inspectImageMetadataFile(file) {
|
||||
if (isImageFile(file)) {
|
||||
if (isMediaFile(file) || isImageFile(file)) {
|
||||
return this.inspectImageFile(file);
|
||||
}
|
||||
if (isWorkflowFile(file)) {
|
||||
return this.inspectWorkflowFile(file);
|
||||
}
|
||||
this.showNotification('Select an image, workflow JSON, or workflow text file.', 'warning');
|
||||
this.showNotification('Select an image, video, audio, workflow JSON, or workflow text file.', 'warning');
|
||||
return null;
|
||||
},
|
||||
|
||||
async inspectImageFile(file) {
|
||||
if (file.size > MAX_IMAGE_FILE_SIZE) {
|
||||
this.showNotification('The image is too large to inspect (maximum 64 MB).', 'error');
|
||||
this.showNotification('The file is too large to inspect (maximum 256 MB).', 'error');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
return this.runImageMetadataInspection({
|
||||
source_type: 'image',
|
||||
filename: file.name || 'Pasted image.png',
|
||||
filename: file.name || 'Pasted media',
|
||||
data_url: dataUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
this.showNotification(error.message || 'Could not read the image file.', 'error');
|
||||
this.showNotification(error.message || 'Could not read the file.', 'error');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -1057,10 +1080,16 @@ export const imageMetadataMethods = {
|
||||
? metadata.metadata.keys.join(', ')
|
||||
: '—';
|
||||
const parameters = String(metadata.metadata?.parameters || '').trim();
|
||||
const isVideo = metadata.source_type === 'video' || image.media_type === 'video' || /\.(mp4|webm|mkv|mov|avi|m4v)$/i.test(metadata.filename || '');
|
||||
const isAudio = metadata.source_type === 'audio' || image.media_type === 'audio' || /\.(flac|mp3|wav|ogg|opus|m4a)$/i.test(metadata.filename || '');
|
||||
const formatLabel = isVideo ? 'Video format' : (isAudio ? 'Audio format' : 'Image format');
|
||||
const modeLabel = isVideo ? 'Pixel format / codec' : (isAudio ? 'Audio info' : 'Color mode');
|
||||
|
||||
const rows = [
|
||||
['Source file', metadata.filename || '—'],
|
||||
image.format ? ['Image format', imageFormat] : null,
|
||||
image.format ? [formatLabel, imageFormat] : null,
|
||||
image.width && image.height ? ['Dimensions', dimensions] : null,
|
||||
image.duration ? ['Duration', `${Number(image.duration).toFixed(1)}s`] : null,
|
||||
['Workflow format', workflowFormat],
|
||||
['Workflow source', metadata.workflow_source || '—'],
|
||||
['Workflow nodes', metadata.workflow
|
||||
@@ -1070,7 +1099,7 @@ export const imageMetadataMethods = {
|
||||
civitaiResources.length
|
||||
? ['Civitai resources', countLabel(civitaiResources.length, 'resource')]
|
||||
: null,
|
||||
image.mode ? ['Color mode', image.mode] : null,
|
||||
image.mode ? [modeLabel, image.mode] : null,
|
||||
['Metadata keys', metadataKeys],
|
||||
].filter(Boolean);
|
||||
const rowsHtml = rows.map(([label, value]) => `
|
||||
@@ -1626,6 +1655,8 @@ export const imageMetadataMethods = {
|
||||
let node = categoryGroup.nodes.get(nodeKey);
|
||||
if (!node) {
|
||||
node = {
|
||||
category,
|
||||
targetKey: `${category}:${nodeKey}`,
|
||||
nodeKey,
|
||||
nodeId: model.node_id,
|
||||
nodeType: String(model.node_type || '').trim(),
|
||||
@@ -1652,6 +1683,26 @@ export const imageMetadataMethods = {
|
||||
}));
|
||||
},
|
||||
|
||||
isMetadataTransferNodeSelected(selectedNodeKeys, node = {}, category = '') {
|
||||
if (!selectedNodeKeys) return false;
|
||||
const set = selectedNodeKeys instanceof Set ? selectedNodeKeys : new Set(selectedNodeKeys);
|
||||
const normalizedCategory = normalizeTransferCategory(category || node?.category);
|
||||
const nodeKey = String(node?.nodeKey || '');
|
||||
const targetKey = String(node?.targetKey || (normalizedCategory && nodeKey ? `${normalizedCategory}:${nodeKey}` : ''));
|
||||
|
||||
if (targetKey && set.has(targetKey)) {
|
||||
return true;
|
||||
}
|
||||
if (nodeKey && set.has(nodeKey)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
getLocateIconHtml() {
|
||||
return `<span class="mr-node-chip-icon" aria-hidden="true">${getSvgIcon('locate')}</span>`;
|
||||
},
|
||||
|
||||
getMetadataTransferModelValue(model = {}) {
|
||||
return String(
|
||||
model?.original_path
|
||||
@@ -1888,9 +1939,10 @@ export const imageMetadataMethods = {
|
||||
for (const group of targetGroups) {
|
||||
const category = normalizeTransferCategory(group.category);
|
||||
for (const node of group.nodes || []) {
|
||||
if (!selectedNodeKeys.has(node.nodeKey)) continue;
|
||||
if (!this.isMetadataTransferNodeSelected(selectedNodeKeys, node, category)) continue;
|
||||
const targetKey = node.targetKey || `${category}:${node.nodeKey}`;
|
||||
for (const ref of node.refs || []) {
|
||||
const target = { category, node, ref, activityScope };
|
||||
const target = { category, node, ref, activityScope, targetKey };
|
||||
target.acceptedFileTypes = this.getMetadataTransferAcceptedFileTypes(target);
|
||||
target.compatibleSources = this.getMetadataTransferCompatibleSourcesForTarget(
|
||||
target,
|
||||
@@ -1977,6 +2029,7 @@ export const imageMetadataMethods = {
|
||||
return {
|
||||
category: target.category,
|
||||
nodeKey: target.node.nodeKey,
|
||||
targetKey: target.targetKey || `${target.category}:${target.node.nodeKey}`,
|
||||
refKey: this.getMetadataTransferSourceSelectionKey(target.ref),
|
||||
nodeName,
|
||||
nodeId: target.node.nodeId,
|
||||
@@ -2008,10 +2061,11 @@ export const imageMetadataMethods = {
|
||||
|
||||
const selectedNodes = new Map();
|
||||
for (const target of selectedTargets) {
|
||||
let entry = selectedNodes.get(target.node.nodeKey);
|
||||
const groupKey = target.targetKey || `${target.category}:${target.node.nodeKey}`;
|
||||
let entry = selectedNodes.get(groupKey);
|
||||
if (!entry) {
|
||||
entry = { node: target.node, targets: [] };
|
||||
selectedNodes.set(target.node.nodeKey, entry);
|
||||
entry = { node: target.node, category: target.category, targets: [] };
|
||||
selectedNodes.set(groupKey, entry);
|
||||
}
|
||||
entry.targets.push(target);
|
||||
}
|
||||
@@ -2281,10 +2335,11 @@ export const imageMetadataMethods = {
|
||||
}
|
||||
|
||||
const previewRows = this.getMetadataTransferPreviewRows(state);
|
||||
const previewRowsByNodeKey = new Map();
|
||||
const previewRowsByTargetKey = new Map();
|
||||
for (const row of previewRows) {
|
||||
if (!previewRowsByNodeKey.has(row.nodeKey)) previewRowsByNodeKey.set(row.nodeKey, []);
|
||||
previewRowsByNodeKey.get(row.nodeKey).push(row);
|
||||
const key = row.targetKey || `${row.category}:${row.nodeKey}`;
|
||||
if (!previewRowsByTargetKey.has(key)) previewRowsByTargetKey.set(key, []);
|
||||
previewRowsByTargetKey.get(key).push(row);
|
||||
}
|
||||
const collapsedCategories = transfer.collapsedCategories instanceof Set
|
||||
? transfer.collapsedCategories
|
||||
@@ -2342,8 +2397,10 @@ export const imageMetadataMethods = {
|
||||
`;
|
||||
}).join('');
|
||||
};
|
||||
const renderNode = node => {
|
||||
const selected = selectedNodeKeys.has(node.nodeKey);
|
||||
const renderNode = (node, category) => {
|
||||
const normalizedCategory = normalizeTransferCategory(category || node.category);
|
||||
const targetKey = node.targetKey || `${normalizedCategory}:${node.nodeKey}`;
|
||||
const selected = this.isMetadataTransferNodeSelected(selectedNodeKeys, node, normalizedCategory);
|
||||
const scopeLabel = node.isTopLevel
|
||||
? ''
|
||||
: ` · ${node.subgraphName || node.subgraphId || 'subgraph'}`;
|
||||
@@ -2360,24 +2417,49 @@ export const imageMetadataMethods = {
|
||||
subgraph_name: node.subgraphName,
|
||||
is_top_level: node.isTopLevel,
|
||||
}, 'Right-click to locate this node in the workflow') || '';
|
||||
const nodePreviewRows = previewRowsByNodeKey.get(node.nodeKey) || [];
|
||||
const nodePreviewRows = previewRowsByTargetKey.get(targetKey)
|
||||
|| (previewRowsByTargetKey.get(node.nodeKey) && previewRows.every(r => r.nodeKey === node.nodeKey) ? previewRowsByTargetKey.get(node.nodeKey) : [])
|
||||
|| [];
|
||||
const previewHtml = selected
|
||||
? nodePreviewRows.length
|
||||
? this.renderMetadataTransferPreview(nodePreviewRows)
|
||||
: '<p class="mr-image-transfer-empty">No imported LoRAs match the selected activity scope.</p>'
|
||||
: renderUnselectedPreview(node);
|
||||
const canLocate = node.nodeId !== undefined && node.nodeId !== null && node.nodeId !== '';
|
||||
const locateTooltip = node.isTopLevel === false
|
||||
? 'Open this subgraph and center the node in the ComfyUI graph.'
|
||||
: 'Center this node in the ComfyUI graph.';
|
||||
const locateButtonHtml = canLocate
|
||||
? `
|
||||
<button type="button"
|
||||
class="mr-node-chip is-locatable mr-missing-row-locate mr-image-transfer-node-locate"
|
||||
data-image-transfer-locate
|
||||
data-node-id="${this.escapeHtml(String(node.nodeId))}"
|
||||
data-subgraph-id="${this.escapeHtml(String(node.subgraphId || ''))}"
|
||||
data-is-top-level="${node.isTopLevel ? 'true' : 'false'}"
|
||||
data-tooltip="${this.escapeHtml(locateTooltip)}"
|
||||
aria-label="Center ${this.escapeHtml(nodeName)} in the ComfyUI graph">
|
||||
${this.getLocateIconHtml()}
|
||||
</button>
|
||||
`
|
||||
: '';
|
||||
return `
|
||||
<div class="mr-image-transfer-node-row${selected ? ' is-selected' : ''}" ${contextMenuAttrs}>
|
||||
<button type="button" class="mr-image-transfer-node-select"
|
||||
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
|
||||
aria-pressed="${selected ? 'true' : 'false'}"
|
||||
aria-disabled="false">
|
||||
<span class="mr-image-transfer-node-check" aria-hidden="true">${selected ? '✓' : ''}</span>
|
||||
<span class="mr-image-transfer-node-details">
|
||||
<span class="mr-image-transfer-node-name">${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}</span>
|
||||
<span class="mr-image-transfer-node-meta">${this.escapeHtml(nodeType)} · ${countText(node.refs.length, 'model slot')} · <span class="mr-image-transfer-node-status ${node.active ? 'is-active' : 'is-inactive'}">${node.active ? 'Active' : 'Inactive'}</span></span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="mr-image-transfer-node-target">
|
||||
<button type="button" class="mr-image-transfer-node-select"
|
||||
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
|
||||
data-image-transfer-target="${this.escapeHtml(targetKey)}"
|
||||
data-image-transfer-category="${this.escapeHtml(normalizedCategory)}"
|
||||
aria-pressed="${selected ? 'true' : 'false'}"
|
||||
aria-disabled="false">
|
||||
<span class="mr-image-transfer-node-check" aria-hidden="true">${selected ? '✓' : ''}</span>
|
||||
<span class="mr-image-transfer-node-details">
|
||||
<span class="mr-image-transfer-node-name">${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}</span>
|
||||
<span class="mr-image-transfer-node-meta">${this.escapeHtml(nodeType)} · ${countText(node.refs.length, 'model slot')} · <span class="mr-image-transfer-node-status ${node.active ? 'is-active' : 'is-inactive'}">${node.active ? 'Active' : 'Inactive'}</span></span>
|
||||
</span>
|
||||
</button>
|
||||
${locateButtonHtml}
|
||||
</div>
|
||||
<div class="mr-image-transfer-node-preview">${previewHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2388,7 +2470,9 @@ export const imageMetadataMethods = {
|
||||
const nodes = Array.isArray(group.nodes) ? group.nodes : [];
|
||||
const isAvailable = sourceEntries.length === 0 || sourceCategories.has(group.category);
|
||||
const isUnavailable = sourceEntries.length > 0 && !isAvailable;
|
||||
const selectedCount = nodes.filter(node => selectedNodeKeys.has(node.nodeKey)).length;
|
||||
const selectedCount = nodes.filter(node => (
|
||||
this.isMetadataTransferNodeSelected(selectedNodeKeys, node, group.category)
|
||||
)).length;
|
||||
const categoryLabel = getCategoryLabel(group.category);
|
||||
const collapsed = collapsedCategories.has(group.category);
|
||||
return `
|
||||
@@ -2404,7 +2488,7 @@ export const imageMetadataMethods = {
|
||||
${isUnavailable ? '<span class="mr-image-transfer-unavailable-pill">No source model</span>' : ''}
|
||||
</span>
|
||||
</div>
|
||||
${collapsed ? '' : `<div class="mr-image-transfer-category-nodes">${nodes.map(renderNode).join('')}</div>`}
|
||||
${collapsed ? '' : `<div class="mr-image-transfer-category-nodes">${nodes.map(node => renderNode(node, group.category)).join('')}</div>`}
|
||||
</section>
|
||||
`;
|
||||
}).join('')
|
||||
@@ -2412,11 +2496,17 @@ export const imageMetadataMethods = {
|
||||
|
||||
const mode = normalizeTransferMode(transfer.mode);
|
||||
const activityScope = normalizeTransferActivityScope(transfer.activityScope);
|
||||
const allTargetNodes = targetGroups.flatMap(group => group.nodes || []);
|
||||
const allTargetNodeKeys = new Set(allTargetNodes.map(node => node.nodeKey));
|
||||
const selectedTargetCount = Array.from(selectedNodeKeys)
|
||||
.filter(nodeKey => allTargetNodeKeys.has(nodeKey))
|
||||
.length;
|
||||
const allTargetNodes = targetGroups.flatMap(group => (
|
||||
(group.nodes || []).map(node => ({
|
||||
node,
|
||||
category: group.category,
|
||||
targetKey: node.targetKey || `${group.category}:${node.nodeKey}`,
|
||||
}))
|
||||
));
|
||||
const allTargetKeys = new Set(allTargetNodes.map(item => item.targetKey));
|
||||
const selectedTargetCount = allTargetNodes.filter(item => (
|
||||
this.isMetadataTransferNodeSelected(selectedNodeKeys, item.node, item.category)
|
||||
)).length;
|
||||
const selectionSourceText = sourceEntries.length
|
||||
? countText(sourceEntries.length, 'metadata model')
|
||||
: 'No source workflow imported';
|
||||
@@ -2486,7 +2576,7 @@ export const imageMetadataMethods = {
|
||||
</label>
|
||||
`).join('')}
|
||||
<div class="mr-image-transfer-selection-tools">
|
||||
<span class="mr-loaded-total">${selectedTargetCount}/${allTargetNodeKeys.size}</span>
|
||||
<span class="mr-loaded-total">${selectedTargetCount}/${allTargetKeys.size}</span>
|
||||
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-image-transfer-clear-selection" data-image-inspector-action="clear-transfer-targets">Clear selection</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2615,9 +2705,14 @@ export const imageMetadataMethods = {
|
||||
state.transfer.targetModels = targetModels;
|
||||
state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels);
|
||||
const targetNodeKeys = new Set(
|
||||
state.transfer.targetGroups.flatMap(group => (
|
||||
(group.nodes || []).map(node => node.nodeKey)
|
||||
))
|
||||
state.transfer.targetGroups.flatMap(group => {
|
||||
const category = normalizeTransferCategory(group.category);
|
||||
return (group.nodes || []).flatMap(node => [
|
||||
node.targetKey,
|
||||
node.targetKey || `${category}:${node.nodeKey}`,
|
||||
node.nodeKey,
|
||||
]).filter(Boolean);
|
||||
})
|
||||
);
|
||||
state.transfer.selectedNodeKeys = new Set(
|
||||
Array.from(state.transfer.selectedNodeKeys || [])
|
||||
@@ -2660,19 +2755,38 @@ export const imageMetadataMethods = {
|
||||
this.renderMetadataTransferPanelInPlace();
|
||||
},
|
||||
|
||||
toggleMetadataTransferNode(nodeKey) {
|
||||
toggleMetadataTransferNode(key, category = '') {
|
||||
const state = getImageInspectorState(this);
|
||||
if (!state.transfer || !nodeKey) return;
|
||||
const nodeGroups = (state.transfer.targetGroups || [])
|
||||
.flatMap(group => group.nodes || [])
|
||||
.filter(node => node.nodeKey === String(nodeKey));
|
||||
if (!nodeGroups.length) return;
|
||||
if (!state.transfer || !key) return;
|
||||
const normalizedKey = String(key).trim();
|
||||
const normalizedCategory = normalizeTransferCategory(category);
|
||||
const targetNodes = (state.transfer.targetGroups || []).flatMap(group => {
|
||||
const groupCategory = normalizeTransferCategory(group.category);
|
||||
return (group.nodes || []).map(node => {
|
||||
const targetKey = node.targetKey || (groupCategory && node.nodeKey ? `${groupCategory}:${node.nodeKey}` : node.nodeKey);
|
||||
return { node, category: groupCategory, targetKey };
|
||||
});
|
||||
});
|
||||
const matched = targetNodes.find(item => item.targetKey === normalizedKey)
|
||||
|| (normalizedCategory
|
||||
? targetNodes.find(item => item.category === normalizedCategory && item.node.nodeKey === normalizedKey)
|
||||
: null)
|
||||
|| targetNodes.find(item => item.node.nodeKey === normalizedKey);
|
||||
if (!matched) return;
|
||||
|
||||
const effectiveKey = (targetNodes.some(item => item.targetKey === normalizedKey))
|
||||
? normalizedKey
|
||||
: (normalizedCategory && matched.node.nodeKey === normalizedKey
|
||||
? `${normalizedCategory}:${matched.node.nodeKey}`
|
||||
: normalizedKey);
|
||||
|
||||
const selected = new Set(state.transfer.selectedNodeKeys || []);
|
||||
if (selected.has(String(nodeKey))) {
|
||||
selected.delete(String(nodeKey));
|
||||
if (selected.has(effectiveKey)) {
|
||||
selected.delete(effectiveKey);
|
||||
} else if (selected.has(matched.node.nodeKey) && effectiveKey !== matched.node.nodeKey) {
|
||||
selected.delete(matched.node.nodeKey);
|
||||
} else {
|
||||
selected.add(String(nodeKey));
|
||||
selected.add(effectiveKey);
|
||||
}
|
||||
state.transfer.selectedNodeKeys = selected;
|
||||
this.renderMetadataTransferPanelInPlace();
|
||||
@@ -2759,7 +2873,7 @@ export const imageMetadataMethods = {
|
||||
for (const group of transfer.targetGroups || []) {
|
||||
const category = normalizeTransferCategory(group.category);
|
||||
for (const node of group.nodes || []) {
|
||||
if (!selectedNodeKeys.has(node.nodeKey)) continue;
|
||||
if (!this.isMetadataTransferNodeSelected(selectedNodeKeys, node, category)) continue;
|
||||
for (const ref of node.refs || []) {
|
||||
const refKey = ref.transferKey || this.getMetadataTransferSourceSelectionKey(ref);
|
||||
if (targetRefKeys.has(refKey)) continue;
|
||||
@@ -2779,7 +2893,13 @@ export const imageMetadataMethods = {
|
||||
} else {
|
||||
sourceIndexes.set(category, sourceIndex + 1);
|
||||
}
|
||||
const target = { category, node, ref, activityScope };
|
||||
const target = {
|
||||
category,
|
||||
node,
|
||||
ref,
|
||||
activityScope,
|
||||
targetKey: node.targetKey || `${category}:${node.nodeKey}`,
|
||||
};
|
||||
target.acceptedFileTypes = this.getMetadataTransferAcceptedFileTypes(target);
|
||||
target.compatibleSources = this.getMetadataTransferCompatibleSourcesForTarget(
|
||||
target,
|
||||
|
||||
Reference in New Issue
Block a user