feat: isolate multi-category transfer targets, add node locator, and expand image metadata formats

This commit is contained in:
Dariusz L
2026-11-02 22:59:15 +01:00
parent 721b4b6e78
commit b68bf311a4
9 changed files with 4466 additions and 3563 deletions
+285 -21
View File
@@ -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:
+2
View File
@@ -14,6 +14,8 @@ _WORKFLOW_CONTAINER_KEYS = (
"extra_pnginfo",
"extra_metadata",
"graph",
"comment",
"description",
)
File diff suppressed because it is too large Load Diff
+146 -1
View File
@@ -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 = {
+202
View File
@@ -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);
});
+62 -2
View File
@@ -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');
});
+22
View File
@@ -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;
}
+4
View File
@@ -1,8 +1,12 @@
const WORKFLOW_CONTAINER_KEYS = new Set([
'comfy',
'comfyui',
'comment',
'description',
'extra_metadata',
'extra_pnginfo',
'graph',
'prompt',
'workflow'
]);
+181 -61
View File
@@ -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,