import { copyTextWithFeedback, safeStorage } from "../utils/html_utils.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import {
isExistingResolvedModel,
toResolverContextModel,
} from "../node_context_menu.js";
import { normalizeCategoryValue } from "../utils/category_utils.js";
import {
extractWorkflowPrompts,
getPromptRoleLabel,
sortPromptCandidates,
updateWorkflowPromptValue,
} from "../utils/prompt_utils.js";
const MAX_IMAGE_FILE_SIZE = 64 * 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';
const METADATA_COLLAPSED_SECTIONS_STORAGE_KEY = 'model_resolver_metadata_collapsed_sections';
const METADATA_PROMPT_ROLES = ['positive', 'negative'];
const MAX_PROMPT_PREVIEW_LENGTH = 12_000;
const COPY_ICON_HTML = ' ';
const MERGE_LORA_NODE_TYPES = new Set([
'LoraLoaderV2',
'Lora Loader (LoraManager)',
'Lora Stacker (LoraManager)',
]);
const MERGE_POWER_LORA_NODE_TYPE = 'Power Lora Loader (rgthree)';
const TRANSFER_ACTIVITY_SCOPES = new Set(['all', 'active', 'inactive']);
function normalizeTransferMode(value) {
const token = String(value || 'replace').trim().toLowerCase().replaceAll('_', '-');
if (token === 'merge' || token === 'add' || token === 'merge-add') return 'merge';
if (token === 'replace-add' || token === 'replaceadd' || token === 'replace-plus-add') {
return 'replace-add';
}
return 'replace';
}
function normalizeTransferActivityScope(value) {
const token = String(value || 'all').trim().toLowerCase().replaceAll('_', '-');
if (token === 'both' || token === 'active-inactive' || token === 'active-and-inactive') {
return 'all';
}
return TRANSFER_ACTIVITY_SCOPES.has(token) ? token : 'all';
}
function isTransferActivityAllowed(value, scope) {
const normalizedScope = normalizeTransferActivityScope(scope);
if (normalizedScope === 'all') return true;
if (normalizedScope === 'inactive') return value === false;
return value !== false;
}
function isAppendableLoraNodeType(nodeType) {
const normalizedType = String(nodeType || '');
return MERGE_LORA_NODE_TYPES.has(normalizedType)
|| normalizedType === MERGE_POWER_LORA_NODE_TYPE;
}
function normalizeTransferCategory(value) {
return normalizeCategoryValue(value);
}
function getTransferNodeKey(model = {}) {
const isTopLevel = model.is_top_level !== false;
const scope = isTopLevel
? 'top'
: `sub:${String(model.subgraph_id || '')}`;
return `${scope}:${String(model.node_id ?? '')}`;
}
function normalizeTransferModelIdentity(value) {
return String(value || '').trim().replaceAll('\\', '/').toLowerCase();
}
function getImageInspectorState(dialog) {
if (!dialog.imageInspectorState) {
dialog.imageInspectorState = {
metadata: null,
loadedModels: null,
error: '',
loading: '',
requestToken: null,
transfer: null,
promptTransfer: null,
lastTransfer: null,
collapsedSections: null,
};
}
return dialog.imageInspectorState;
}
function cloneWorkflowSnapshot(workflow) {
if (!workflow || typeof workflow !== 'object') return null;
if (typeof structuredClone === 'function') {
try {
return structuredClone(workflow);
} catch {
// Fall back to JSON for plain serialized workflow values.
}
}
try {
return JSON.parse(JSON.stringify(workflow));
} catch {
return null;
}
}
function isImageFile(file) {
const name = String(file?.name || '').toLowerCase();
return String(file?.type || '').startsWith('image/')
|| /\.(png|jpe?g|webp)$/i.test(name);
}
function isWorkflowFile(file) {
const name = String(file?.name || '').toLowerCase();
return file?.type === 'application/json'
|| file?.type === 'text/json'
|| file?.type === 'text/plain'
|| name.endsWith('.json')
|| name.endsWith('.txt');
}
function readFileAsText(file) {
if (typeof file?.text === 'function') {
return file.text();
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Could not read the workflow file.'));
reader.readAsText(file);
});
}
export function extractJsonFromText(rawText) {
if (!rawText || typeof rawText !== 'string') return null;
let text = rawText.replace(/^\uFEFF/, '').trim();
if (!text) return null;
if (text.startsWith('```')) {
const firstLineEnd = text.indexOf('\n');
if (firstLineEnd !== -1) {
const lastFence = text.lastIndexOf('```');
if (lastFence > firstLineEnd) {
text = text.slice(firstLineEnd + 1, lastFence).trim();
}
}
}
try {
return JSON.parse(text);
} catch {
const firstBrace = text.indexOf('{');
const lastBrace = text.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
try {
return JSON.parse(text.slice(firstBrace, lastBrace + 1));
} catch {
// Ignore nested extraction failure.
}
}
const firstBracket = text.indexOf('[');
const lastBracket = text.lastIndexOf(']');
if (firstBracket !== -1 && lastBracket > firstBracket) {
try {
return JSON.parse(text.slice(firstBracket, lastBracket + 1));
} catch {
// Ignore nested extraction failure.
}
}
return null;
}
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Could not read the image file.'));
reader.readAsDataURL(file);
});
}
function extractClipboardFile(clipboardData) {
if (!clipboardData) return null;
const files = Array.from(clipboardData.files || []);
for (const file of files) {
if (isImageFile(file) || isWorkflowFile(file)) {
return file;
}
}
const items = Array.from(clipboardData.items || []);
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile?.();
if (file && (isImageFile(file) || isWorkflowFile(file))) {
return file;
}
}
}
return null;
}
export function extractCivitaiImageId(input) {
if (!input || typeof input !== 'string') return null;
const str = input.trim();
// 1. Direct or full URL: https://civitai.com/images/12345 or https://civitai.red/images/12345
const urlMatch = str.match(/https?:\/\/(?:www\.)?civitai\.(?:com|red)\/images\/([1-9]\d*)/i);
if (urlMatch) {
return parseInt(urlMatch[1], 10);
}
// 2. Relative link in HTML: href="/images/12345" or href='/images/12345'
const hrefMatch = str.match(/href=["'](?:\/|https?:\/\/(?:www\.)?civitai\.(?:com|red)\/)?images\/([1-9]\d*)/i);
if (hrefMatch) {
return parseInt(hrefMatch[1], 10);
}
// 3. Image CDN URL: https://image.civitai.com/.../12345.jpeg or similar
const cdnMatch = str.match(/image\.civitai\.com\/[^\s"'<>]*\/([1-9]\d*)\.[a-zA-Z0-9]+/i);
if (cdnMatch) {
return parseInt(cdnMatch[1], 10);
}
// 4. HTML data attribute: data-image-id="12345"
const dataIdMatch = str.match(/data-image-id=["']([1-9]\d*)["']/i);
if (dataIdMatch) {
return parseInt(dataIdMatch[1], 10);
}
return null;
}
function isExternalInputElement(element, root) {
if (!element) return false;
if (typeof document !== 'undefined') {
if (element === document.body || element === document.documentElement) {
return false;
}
}
if (root && root.contains(element)) {
return false;
}
const tagName = element.tagName?.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true;
}
return Boolean(element.isContentEditable);
}
function isExternalInputActive(root) {
const active = typeof document !== 'undefined' ? document.activeElement : null;
return isExternalInputElement(active, root);
}
function countLabel(count, singular, plural = `${singular}s`) {
const value = Number(count) || 0;
return `${value} ${value === 1 ? singular : plural}`;
}
function buildCivitaiModelData(resources = []) {
const models = resources.map((resource, index) => {
const modelVersionId = resource?.model_version_id
?? resource?.modelVersionId
?? '';
const rawStrength = resource?.strength;
const numericStrength = Number(rawStrength);
const strength = Number.isFinite(numericStrength) ? numericStrength : null;
const filename = String(resource?.filename || resource?.file_name || '').trim();
const modelName = String(
resource?.model_name
|| resource?.modelName
|| resource?.display_name
|| ''
).trim();
const versionName = String(resource?.version_name || resource?.versionName || '').trim();
const rawName = String(resource?.name || '').trim();
const name = filename || rawName || modelName || (modelVersionId ? `Civitai model ${modelVersionId}` : '');
if (!name) return null;
const originalPath = String(resource?.original_path || filename || name).trim();
const friendlyTitle = modelName && versionName
? `${modelName} (${versionName})`
: (modelName || versionName || name);
return {
name,
model_name: modelName,
version_name: versionName,
filename: filename || name,
category: String(resource?.category || 'unknown'),
node_id: `civitai-resource-${modelVersionId || index}`,
widget_index: null,
node_type: 'Civitai metadata',
node_title: friendlyTitle,
exists: Boolean(resource?.exists),
active: resource?.active !== false && (strength === null || strength !== 0),
connected: true,
strength,
original_path: originalPath,
resolved_path: resource?.resolved_path || resource?.path || '',
path: resource?.path || resource?.resolved_path || '',
model_id: resource?.model_id ?? resource?.modelId ?? null,
model_version_id: modelVersionId || null,
source: 'civitai_metadata',
base_model: resource?.base_model || null,
download_url: resource?.download_url || null,
hashes: resource?.hashes || {},
trained_words: Array.isArray(resource?.trained_words) ? resource.trained_words : [],
};
}).filter(Boolean);
return {
loaded_models: models,
total: models.length,
};
}
export const imageMetadataMethods = {
isMetadataTransferCurrentWorkflow(state = null, workflow = null) {
const transfer = state?.transfer;
if (!transfer) return false;
if (!workflow) return !transfer.workflowSignature;
const workflowSignature = this.getWorkflowSignature?.(workflow) || '';
return Boolean(workflowSignature) && transfer.workflowSignature === workflowSignature;
},
getImageInspectorCollapsedSections(state = null) {
const rootState = getImageInspectorState(this);
const imageState = state || rootState;
if (rootState && rootState.collapsedSections instanceof Set) {
imageState.collapsedSections = rootState.collapsedSections;
return rootState.collapsedSections;
}
if (!(imageState.collapsedSections instanceof Set)) {
let initial = [];
const stored = safeStorage.getItem(METADATA_COLLAPSED_SECTIONS_STORAGE_KEY);
if (stored) {
try {
initial = JSON.parse(stored);
} catch {
initial = [];
}
}
const set = new Set(Array.isArray(initial) ? initial : []);
imageState.collapsedSections = set;
if (rootState) rootState.collapsedSections = set;
}
return imageState.collapsedSections;
},
isImageInspectorSectionCollapsed(sectionId, state = null) {
if (!sectionId) return false;
const collapsed = this.getImageInspectorCollapsedSections(state);
return collapsed instanceof Set ? collapsed.has(sectionId) : false;
},
toggleImageInspectorSection(sectionId) {
if (!sectionId) return;
const state = getImageInspectorState(this);
const collapsed = this.getImageInspectorCollapsedSections(state);
const nextCollapsed = !collapsed.has(sectionId);
if (nextCollapsed) {
collapsed.add(sectionId);
} else {
collapsed.delete(sectionId);
}
try {
safeStorage.setItem(
METADATA_COLLAPSED_SECTIONS_STORAGE_KEY,
JSON.stringify(Array.from(collapsed)),
);
} catch {
// ignore storage errors
}
this.applyImageInspectorSectionCollapsed(sectionId, nextCollapsed);
},
applyImageInspectorSectionCollapsed(sectionId, isCollapsed) {
const root = this.contentElement?.querySelector('.mr-image-inspector');
if (!root) return;
let container = null;
if (sectionId === 'tools') {
root.classList.toggle('is-tools-collapsed', isCollapsed);
} else if (sectionId === 'summary') {
container = root.querySelector('.mr-image-inspector-summary');
} else if (sectionId === 'prompt-transfer') {
container = root.querySelector('.mr-image-prompt-transfer');
} else if (sectionId === 'model-transfer') {
container = root.querySelector('.mr-image-transfer');
} else if (sectionId === 'models') {
container = root.querySelector('.mr-image-inspector-models');
}
if (container) {
container.classList.toggle('is-collapsed', isCollapsed);
}
const btn = root.querySelector(`[data-image-inspector-collapse="${sectionId}"]`);
if (btn) {
btn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
}
},
loadImageMetadata() {
if (!this.contentElement) return null;
this.contentElement.style.overflowY = 'auto';
const state = getImageInspectorState(this);
const workflow = this.getCurrentWorkflow?.() || null;
const shouldOpenTransfer = !this.isMetadataTransferCurrentWorkflow(state, workflow);
this.renderImageMetadataShell();
if (shouldOpenTransfer) void this.openMetadataTransfer();
return null;
},
renderImageMetadataShell() {
if (!this.contentElement) return;
const isToolsCollapsed = this.isImageInspectorSectionCollapsed('tools');
const existingRoot = this.contentElement.querySelector('.mr-image-inspector');
if (!existingRoot) {
this.contentElement.innerHTML = `
`;
this.bindImageMetadataEvents();
} else {
existingRoot.classList.toggle('is-tools-collapsed', isToolsCollapsed);
const btn = existingRoot.querySelector('[data-image-inspector-collapse="tools"]');
if (btn) btn.setAttribute('aria-expanded', isToolsCollapsed ? 'false' : 'true');
}
this.bindTooltips?.(this.contentElement);
this.renderImageMetadataResult();
},
bindImageMetadataEvents() {
const root = this.contentElement?.querySelector('.mr-image-inspector');
if (!root || root.dataset.imageInspectorBound === '1') return;
root.dataset.imageInspectorBound = '1';
if (typeof this._metadataPasteCleanup === 'function') {
this._metadataPasteCleanup();
this._metadataPasteCleanup = null;
}
const onMouseEnter = () => {
this._isMouseOverMetadata = true;
const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
const docBody = typeof document !== 'undefined' ? document.body : null;
if (!isExternalInputActive(root) && activeEl === docBody) {
root.focus?.({ preventScroll: true });
}
};
const onMouseLeave = (e) => {
const relTarget = e.relatedTarget;
if (relTarget && (root.contains(relTarget) || this.contentElement?.contains(relTarget))) {
return;
}
this._isMouseOverMetadata = false;
};
root.addEventListener('mouseenter', onMouseEnter);
root.addEventListener('mouseleave', onMouseLeave);
if (this.contentElement && this.contentElement !== root) {
this.contentElement.addEventListener('mouseenter', onMouseEnter);
this.contentElement.addEventListener('mouseleave', onMouseLeave);
}
const onPasteCapture = (event) => {
this.handleImageMetadataPaste(event);
};
if (typeof document !== 'undefined') {
document.addEventListener('paste', onPasteCapture, true);
}
this._metadataPasteCleanup = () => {
this._isMouseOverMetadata = false;
if (typeof document !== 'undefined') {
document.removeEventListener('paste', onPasteCapture, true);
}
root.removeEventListener('mouseenter', onMouseEnter);
root.removeEventListener('mouseleave', onMouseLeave);
if (this.contentElement && this.contentElement !== root) {
this.contentElement.removeEventListener('mouseenter', onMouseEnter);
this.contentElement.removeEventListener('mouseleave', onMouseLeave);
}
if (root) {
delete root.dataset.imageInspectorBound;
}
};
const fileInput = root.querySelector('[data-image-inspector-input="file"]');
const pasteInput = root.querySelector('.mr-image-inspector-textarea');
const analyzePasteButton = root.querySelector('[data-image-inspector-action="analyze-paste"]');
const updateAnalyzePasteButton = () => {
if (analyzePasteButton) {
const val = (pasteInput?.value || '').trim();
analyzePasteButton.disabled = !val;
if (extractCivitaiImageId(val)) {
analyzePasteButton.textContent = 'Analyze Civitai';
} else {
analyzePasteButton.textContent = 'Analyze JSON';
}
}
};
const setDragOver = (active) => {
root.classList.toggle('is-dragover', active);
};
const isFileDrag = (event) => {
const types = Array.from(event.dataTransfer?.types || []);
return types.includes('Files') || Boolean(event.dataTransfer?.files?.length);
};
let dragDepth = 0;
root.addEventListener('click', (event) => {
const collapseToggle = event.target.closest?.('[data-image-inspector-collapse]');
if (collapseToggle) {
event.preventDefault();
event.stopPropagation();
this.toggleImageInspectorSection(collapseToggle.dataset.imageInspectorCollapse);
return;
}
const categoryToggle = event.target.closest?.('[data-image-transfer-category-toggle]');
if (categoryToggle) {
this.toggleMetadataTransferCategory(categoryToggle.dataset.imageTransferCategoryToggle);
return;
}
const nodeChip = event.target.closest?.('[data-image-transfer-node]');
if (nodeChip) {
this.toggleMetadataTransferNode(nodeChip.dataset.imageTransferNode);
return;
}
const promptCopyButton = event.target.closest?.('[data-image-prompt-copy]');
if (promptCopyButton) {
void this.copyMetadataPromptText(promptCopyButton);
return;
}
const action = event.target.closest?.('[data-image-inspector-action]')?.dataset?.imageInspectorAction;
if (!action) return;
if (action === 'choose-file') fileInput?.click();
if (action === 'clear') this.clearImageMetadata();
if (action === 'clear-transfer-targets') this.clearMetadataTransferTargets();
if (action === 'retry-transfer-targets') void this.openMetadataTransfer({ force: true });
if (action === 'undo-transfer') void this.undoMetadataTransfer();
if (action === 'apply-transfer') void this.applyMetadataTransfer();
if (action === 'apply-prompts') void this.applyMetadataPromptTransfer();
if (action === 'analyze-paste') {
const textarea = root.querySelector('.mr-image-inspector-textarea');
void this.inspectPastedWorkflow(textarea?.value || '');
}
});
root.addEventListener('change', (event) => {
const input = event.target;
if (input.matches?.('[data-image-transfer-source]')) {
const state = getImageInspectorState(this);
if (state.transfer) {
const existingSourceSelections = state.transfer.sourceSelections;
const sourceSelections = existingSourceSelections instanceof Map
? Object.fromEntries(existingSourceSelections)
: existingSourceSelections && typeof existingSourceSelections === 'object'
? { ...existingSourceSelections }
: {};
const selectionKey = input.dataset.imageTransferSource;
if (selectionKey) {
if (input.value) sourceSelections[selectionKey] = input.value;
else delete sourceSelections[selectionKey];
}
state.transfer.sourceSelections = sourceSelections;
this.renderMetadataTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-prompt-source]')) {
const state = getImageInspectorState(this);
const role = String(input.dataset.imagePromptSource || '').trim();
if (state.promptTransfer && METADATA_PROMPT_ROLES.includes(role)) {
state.promptTransfer.sourceSelections = {
...(state.promptTransfer.sourceSelections || {}),
[role]: input.value,
};
this.renderMetadataPromptTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-prompt-role-selection]')) {
const state = getImageInspectorState(this);
const role = String(input.dataset.imagePromptRoleSelection || '').trim();
if (state.promptTransfer && METADATA_PROMPT_ROLES.includes(role)) {
const selectedRoles = new Set(this.getMetadataPromptTransferState(state).selectedRoles);
if (input.checked) selectedRoles.add(role);
else selectedRoles.delete(role);
state.promptTransfer.selectedRoles = selectedRoles;
this.renderMetadataPromptTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-prompt-target]')) {
const state = getImageInspectorState(this);
const role = String(input.dataset.imagePromptTarget || '').trim();
if (state.promptTransfer && METADATA_PROMPT_ROLES.includes(role)) {
state.promptTransfer.targetSelections = {
...(state.promptTransfer.targetSelections || {}),
[role]: input.value,
};
this.renderMetadataPromptTransferPanelInPlace();
}
return;
}
if (input.matches?.('[data-image-transfer-mode]')) {
const state = getImageInspectorState(this);
if (state.transfer) state.transfer.mode = this.setMetadataTransferMode(input.value);
this.renderMetadataTransferPanelInPlace();
return;
}
if (input.matches?.('[data-image-transfer-activity-scope]')) {
const state = getImageInspectorState(this);
if (state.transfer) {
state.transfer.activityScope = this.setMetadataTransferActivityScope(input.value);
}
this.renderMetadataTransferPanelInPlace();
return;
}
});
pasteInput?.addEventListener('input', updateAnalyzePasteButton);
updateAnalyzePasteButton();
fileInput?.addEventListener('change', (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void this.inspectImageMetadataFile(file);
});
root.addEventListener('dragenter', (event) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth += 1;
setDragOver(true);
});
root.addEventListener('dragover', (event) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
setDragOver(true);
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
root.addEventListener('dragleave', (event) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0 || (event.relatedTarget && !root.contains(event.relatedTarget))) {
setDragOver(false);
}
});
root.addEventListener('drop', (event) => {
if (!isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth = 0;
setDragOver(false);
const file = event.dataTransfer?.files?.[0];
if (file) void this.inspectImageMetadataFile(file);
});
},
handleImageMetadataPaste(event) {
if (this.activeTab !== 'metadata') return false;
if (this.element && this.element.style.display === 'none') return false;
const root = this.contentElement?.querySelector('.mr-image-inspector');
if (!root) return false;
if (isExternalInputElement(event.target, root) || isExternalInputActive(root)) {
return false;
}
const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
const isTargetInside = Boolean(
(event.target && root.contains(event.target))
|| (activeEl && root.contains(activeEl))
);
const isHovered = Boolean(
this._isMouseOverMetadata
|| root.matches?.(':hover')
|| this.contentElement?.matches?.(':hover')
);
if (!isTargetInside && !isHovered) {
return false;
}
const clipboardData = event.clipboardData || (typeof window !== 'undefined' ? window.clipboardData : null);
if (!clipboardData) return false;
const targetIsTextarea = event.target?.classList?.contains('mr-image-inspector-textarea')
|| event.target?.id === 'mr-image-inspector-json-text';
const htmlText = clipboardData.getData?.('text/html') || '';
const plainText = clipboardData.getData?.('text/plain') || '';
const civitaiFromPlain = extractCivitaiImageId(plainText);
const civitaiFromHtml = extractCivitaiImageId(htmlText);
const civitaiImageId = civitaiFromPlain || civitaiFromHtml;
if (civitaiImageId) {
event.preventDefault?.();
event.stopPropagation?.();
const sourceUrl = civitaiFromPlain
? plainText.trim()
: `https://civitai.com/images/${civitaiImageId}`;
const textarea = root.querySelector('.mr-image-inspector-textarea');
if (textarea) {
textarea.value = sourceUrl;
if (typeof Event === 'function') {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
}
void this.inspectCivitaiImage(civitaiImageId, sourceUrl);
return true;
}
const file = extractClipboardFile(clipboardData);
if (file) {
if (isImageFile(file)) {
event.preventDefault?.();
event.stopPropagation?.();
void this.inspectImageFile(file);
return true;
}
if (isWorkflowFile(file)) {
event.preventDefault?.();
event.stopPropagation?.();
void this.inspectWorkflowFile(file);
return true;
}
}
const text = clipboardData.getData?.('text/plain') || '';
const trimmed = text.trim();
if (/^data:image\/(png|jpeg|jpg|webp);base64,/i.test(trimmed)) {
event.preventDefault?.();
event.stopPropagation?.();
void this.runImageMetadataInspection({
source_type: 'image',
filename: 'Pasted image',
data_url: trimmed,
});
return true;
}
const parsedWorkflow = extractJsonFromText(trimmed);
if (parsedWorkflow && typeof parsedWorkflow === 'object') {
event.preventDefault?.();
event.stopPropagation?.();
const textarea = root.querySelector('.mr-image-inspector-textarea');
if (textarea) {
textarea.value = trimmed;
if (typeof Event === 'function') {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
}
void this.inspectPastedWorkflow(trimmed);
return true;
}
if (targetIsTextarea) {
return false;
}
event.preventDefault?.();
event.stopPropagation?.();
this.showNotification('The clipboard does not contain an image, Civitai link, or valid workflow JSON.', 'warning');
return true;
},
async inspectImageMetadataFile(file) {
if (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');
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');
return null;
}
try {
const dataUrl = await readFileAsDataUrl(file);
return this.runImageMetadataInspection({
source_type: 'image',
filename: file.name || 'Pasted image.png',
data_url: dataUrl,
});
} catch (error) {
this.showNotification(error.message || 'Could not read the image file.', 'error');
return null;
}
},
async inspectWorkflowFile(file) {
if (file.size > MAX_WORKFLOW_FILE_SIZE) {
this.showNotification('The workflow JSON is too large to inspect (maximum 16 MB).', 'error');
return null;
}
try {
const raw = await readFileAsText(file);
const text = (raw || '').replace(/^\uFEFF/, '').trim();
const civitaiImageId = extractCivitaiImageId(text);
if (civitaiImageId) {
return this.inspectCivitaiImage(civitaiImageId, text);
}
const workflow = extractJsonFromText(text);
if (!workflow || typeof workflow !== 'object') {
throw new Error('Not valid JSON');
}
return this.runImageMetadataInspection({
source_type: 'workflow',
filename: file.name || 'Pasted workflow.json',
workflow,
});
} catch (error) {
const name = String(file?.name || '').toLowerCase();
const isTxt = name.endsWith('.txt') || file?.type === 'text/plain';
const msg = isTxt
? 'The text file does not contain valid workflow JSON.'
: (error.message && error.message !== 'Not valid JSON' ? error.message : 'The workflow file is not valid JSON.');
this.showNotification(msg, 'error');
return null;
}
},
async inspectPastedWorkflow(text) {
const value = String(text || '').trim();
if (!value) {
this.showNotification('Paste a workflow JSON object or Civitai image link first.', 'warning');
return null;
}
const civitaiImageId = extractCivitaiImageId(value);
if (civitaiImageId) {
return this.inspectCivitaiImage(civitaiImageId, value);
}
if (new TextEncoder().encode(value).length > MAX_WORKFLOW_FILE_SIZE) {
this.showNotification('The workflow JSON is too large to inspect (maximum 16 MB).', 'error');
return null;
}
try {
return this.runImageMetadataInspection({
source_type: 'workflow',
filename: 'Pasted workflow',
json_text: value,
});
} catch (error) {
this.showNotification(error.message || 'The pasted workflow is not valid JSON.', 'error');
return null;
}
},
async inspectCivitaiImage(imageId, sourceUrl = '') {
const id = parseInt(imageId, 10);
if (!id || id <= 0) {
this.showNotification('Invalid Civitai image ID.', 'warning');
return null;
}
const tokens = this.getStoredTokens?.() || {};
return this.runImageMetadataInspection({
source_type: 'civitai',
image_id: id,
url: sourceUrl || `https://civitai.com/images/${id}`,
filename: `Civitai image ${id}`,
civitai_key: tokens.civitai_key || '',
});
},
async runImageMetadataInspection(payload) {
const state = getImageInspectorState(this);
const requestToken = `image-inspector-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
state.requestToken = requestToken;
state.metadata = null;
state.loadedModels = null;
if (state.promptTransfer) {
state.promptTransfer.applying = false;
state.promptTransfer.error = '';
state.promptTransfer.sourceSelections = {};
}
if (state.transfer) {
state.transfer.requestToken = null;
state.transfer.loading = false;
state.transfer.applying = false;
state.transfer.error = '';
state.transfer.sourceSelections = {};
}
state.error = '';
state.loading = 'metadata';
this.renderImageMetadataResult();
try {
const metadata = await this.fetchJson(
'/model_resolver/inspect-metadata',
{ method: 'POST', body: JSON.stringify(payload) },
'Read workflow metadata'
);
if (state.requestToken !== requestToken) return null;
state.metadata = metadata;
if (!metadata?.workflow) {
const civitaiResources = Array.isArray(metadata.civitai_resources)
? metadata.civitai_resources
: [];
if (civitaiResources.length) {
state.loadedModels = buildCivitaiModelData(civitaiResources);
}
state.loading = '';
this.renderImageMetadataResult();
void this.openMetadataTransfer();
return metadata;
}
state.loading = 'models';
this.renderImageMetadataResult();
const loadedProgressId = `inspector-loaded-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const loadedModels = await this.fetchJson(
'/model_resolver/loaded',
{
method: 'POST',
body: JSON.stringify({
workflow: metadata.workflow,
loaded_id: loadedProgressId,
}),
},
'Scan workflow models'
);
if (state.requestToken !== requestToken) return null;
state.loadedModels = loadedModels;
state.loading = '';
this.renderImageMetadataResult();
void this.openMetadataTransfer();
return metadata;
} catch (error) {
if (state.requestToken !== requestToken) return null;
state.loading = '';
state.error = error?.message || 'Could not inspect workflow metadata.';
this.renderImageMetadataResult();
return null;
}
},
clearImageMetadata() {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const state = getImageInspectorState(this);
state.requestToken = `cleared-${Date.now()}`;
state.metadata = null;
state.loadedModels = null;
state.error = '';
state.loading = '';
this.renderImageMetadataShell();
void this.openMetadataTransfer();
},
renderImageMetadataResult() {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
if (!result) return;
const state = getImageInspectorState(this);
if (state.loading) {
const message = state.loading === 'models'
? 'Scanning workflow models...'
: 'Reading embedded metadata...';
result.innerHTML = `
${this.escapeHtml(message)}
${this.renderMetadataPromptTransferPanel(state)}
${this.renderMetadataTransferPanel(state)}
`;
return;
}
if (state.error) {
result.innerHTML = `
${this.escapeHtml(state.error)}
${this.renderMetadataPromptTransferPanel(state)}
${this.renderMetadataTransferPanel(state)}
`;
return;
}
if (!state.metadata) {
result.innerHTML = `${this.renderMetadataPromptTransferPanel(state)}${this.renderMetadataTransferPanel(state)}`;
return;
}
const metadata = state.metadata;
const image = metadata.image || {};
const imageFormat = image.format || '—';
const dimensions = image.width && image.height
? `${image.width} × ${image.height}`
: '—';
const workflowFormat = metadata.workflow_format
? String(metadata.workflow_format).toUpperCase()
: 'Not found';
const civitaiWorkflow = String(metadata.civitai_workflow || '').trim();
const civitaiResources = Array.isArray(metadata.civitai_resources)
? metadata.civitai_resources
: [];
const civitaiModelData = buildCivitaiModelData(civitaiResources);
const metadataKeys = Array.isArray(metadata.metadata?.keys)
? metadata.metadata.keys.join(', ')
: '—';
const parameters = String(metadata.metadata?.parameters || '').trim();
const rows = [
['Source file', metadata.filename || '—'],
image.format ? ['Image format', imageFormat] : null,
image.width && image.height ? ['Dimensions', dimensions] : null,
['Workflow format', workflowFormat],
['Workflow source', metadata.workflow_source || '—'],
['Workflow nodes', metadata.workflow
? countLabel(metadata.workflow_node_count, 'node')
: 'No embedded workflow'],
civitaiWorkflow ? ['Civitai workflow', civitaiWorkflow] : null,
civitaiResources.length
? ['Civitai resources', countLabel(civitaiResources.length, 'resource')]
: null,
image.mode ? ['Color mode', image.mode] : null,
['Metadata keys', metadataKeys],
].filter(Boolean);
const rowsHtml = rows.map(([label, value]) => `
${this.escapeHtml(label)}
${this.escapeHtml(value)}
`).join('');
const parametersHtml = parameters ? `
${this.escapeHtml('Generation parameters')}
${this.escapeHtml(parameters)}
` : '';
const modelContainerId = `mr-image-inspector-models-${Date.now()}`;
const isSummaryCollapsed = this.isImageInspectorSectionCollapsed('summary', state);
const isModelsCollapsed = this.isImageInspectorSectionCollapsed('models', state);
result.innerHTML = `
${rowsHtml}${parametersHtml}
${this.renderMetadataPromptTransferPanel(state)}
${this.renderMetadataTransferPanel(state)}
`;
const modelContainer = result.querySelector(`#${modelContainerId}`);
if (!metadata.workflow && civitaiModelData.total) {
this.displayLoadedModels(modelContainer, civitaiModelData, {
title: 'Referenced Models',
tooltip: 'Models reported by embedded Civitai metadata.',
includeContextMenu: false,
collapsible: true,
isCollapsed: isModelsCollapsed,
sectionId: 'models',
});
} else if (!metadata.workflow) {
modelContainer.innerHTML = this.renderStatusMessage(
'No supported ComfyUI workflow was found in this image.',
'info'
);
} else if (state.loadedModels) {
this.displayLoadedModels(modelContainer, state.loadedModels, {
title: 'Referenced Models',
tooltip: 'Models detected in the imported ComfyUI workflow.',
collapsible: true,
isCollapsed: isModelsCollapsed,
sectionId: 'models',
});
} else {
modelContainer.innerHTML = this.renderStatusMessage(
'No loaded model scan is available yet.',
'info'
);
}
this.bindTooltips?.(result);
},
getMetadataPromptTransferState(state = null) {
const imageState = state || getImageInspectorState(this);
if (!imageState.promptTransfer) {
imageState.promptTransfer = {
applying: false,
error: '',
sourceSelections: {},
targetSelections: {},
selectedRoles: new Set(METADATA_PROMPT_ROLES),
};
}
if (!(imageState.promptTransfer.selectedRoles instanceof Set)) {
const selectedRoles = Array.isArray(imageState.promptTransfer.selectedRoles)
? imageState.promptTransfer.selectedRoles
: METADATA_PROMPT_ROLES;
imageState.promptTransfer.selectedRoles = new Set(
selectedRoles.filter(role => METADATA_PROMPT_ROLES.includes(role)),
);
}
return imageState.promptTransfer;
},
getMetadataPromptLabel(prompt = {}, fallback = 'Text prompt') {
const nodeLabel = String(
prompt.node_title
|| prompt.node_type
|| fallback
).trim() || fallback;
const nodeId = prompt.node_id === undefined || prompt.node_id === null
? ''
: ` · Node ${String(prompt.node_id)}`;
const scope = prompt.is_top_level === false
? ` · ${String(prompt.subgraph_name || prompt.subgraph_id || 'subgraph')}`
: '';
const widget = prompt.widget_name ? ` · ${String(prompt.widget_name)}` : '';
return `${nodeLabel}${nodeId}${scope}${widget}`;
},
getMetadataPromptNodeContextMenuAttrs(prompt = {}) {
const nodeId = prompt.node_id;
if (
typeof this.getContextMenuAttrs !== 'function'
|| nodeId === undefined
|| nodeId === null
|| nodeId === ''
) {
return '';
}
return this.getContextMenuAttrs({
context_scope: 'workflow_node',
node_id: nodeId,
node_type: prompt.node_type,
node_title: prompt.node_title,
subgraph_id: prompt.subgraph_id,
subgraph_name: prompt.subgraph_name,
is_top_level: prompt.is_top_level,
}, 'Right-click to locate this node in the workflow');
},
getMetadataPromptPreviewText(prompt = {}) {
const text = String(prompt.text || '');
if (text.length <= MAX_PROMPT_PREVIEW_LENGTH) return text;
return `${text.slice(0, MAX_PROMPT_PREVIEW_LENGTH)}\n…`;
},
getMetadataPromptCopyButton(prompt = {}) {
if (!String(prompt.text || '').trim()) return '';
return `
${COPY_ICON_HTML} Copy
`;
},
async copyMetadataPromptText(button) {
const textElement = button?.closest?.('.mr-image-prompt-side')
?.querySelector?.('.mr-image-prompt-text');
const text = textElement?.textContent || '';
if (!text) return false;
await copyTextWithFeedback(String(text), button, {
successClass: 'is-copied',
successHtml: `${COPY_ICON_HTML} Copied`,
errorText: 'Failed',
duration: 1200,
});
return true;
},
getMetadataTooltipBadge(text, ariaLabel = 'More information') {
const tooltip = String(text ?? '').trim();
if (!tooltip) return '';
const label = String(ariaLabel ?? 'More information').trim() || 'More information';
return `? `;
},
getMetadataPromptTransferData(state = null) {
const imageState = state || getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(imageState);
const sourcePrompts = extractWorkflowPrompts(imageState.metadata?.workflow || {})
.filter(prompt => (!prompt.linked || prompt.has_resolved_text) && String(prompt.text || '').trim());
if (!sourcePrompts.length && imageState.metadata?.civitai_metadata) {
const cm = imageState.metadata.civitai_metadata;
if (cm.prompt && typeof cm.prompt === 'string' && cm.prompt.trim()) {
sourcePrompts.push({
id: 'civitai:positive',
node_id: 'civitai-prompt-positive',
node_type: 'Civitai metadata',
node_title: 'Civitai positive prompt',
widget_index: 0,
widget_name: 'prompt',
text: cm.prompt.trim(),
role: 'positive',
roleConfidence: 1,
isDedicated: true,
linked: false,
});
}
if (cm.negativePrompt && typeof cm.negativePrompt === 'string' && cm.negativePrompt.trim()) {
sourcePrompts.push({
id: 'civitai:negative',
node_id: 'civitai-prompt-negative',
node_type: 'Civitai metadata',
node_title: 'Civitai negative prompt',
widget_index: 0,
widget_name: 'negativePrompt',
text: cm.negativePrompt.trim(),
role: 'negative',
roleConfidence: 1,
isDedicated: true,
linked: false,
});
}
}
const targetPrompts = extractWorkflowPrompts(this.getCurrentWorkflow?.() || {})
.filter(prompt => !prompt.linked);
const selectedRoles = this.getMetadataPromptTransferState(imageState).selectedRoles;
const sourceSelections = promptTransfer.sourceSelections || {};
const targetSelections = promptTransfer.targetSelections || {};
const usedSourceIds = new Set();
const usedTargetIds = new Set();
const rows = METADATA_PROMPT_ROLES.map(role => {
const sourceOptions = sortPromptCandidates(
sourcePrompts.filter(prompt => (
prompt.role === role || prompt.role === 'unknown'
)),
role,
);
const targetOptions = sortPromptCandidates(
targetPrompts.filter(prompt => (
prompt.role === role || prompt.role === 'unknown'
)),
role,
);
const requestedSource = sourceOptions.find(prompt => (
String(prompt.id) === String(sourceSelections[role] || '')
));
const selectedSource = requestedSource
|| sourceOptions.find(prompt => prompt.role === role)
|| sourceOptions.find(prompt => !usedSourceIds.has(String(prompt.id)))
|| sourceOptions[0]
|| null;
const requestedTarget = targetOptions.find(prompt => (
String(prompt.id) === String(targetSelections[role] || '')
));
const selectedTarget = requestedTarget
|| targetOptions.find(prompt => prompt.role === role)
|| targetOptions.find(prompt => !usedTargetIds.has(String(prompt.id)))
|| targetOptions[0]
|| null;
if (selectedSource) usedSourceIds.add(String(selectedSource.id));
if (selectedTarget) usedTargetIds.add(String(selectedTarget.id));
return {
role,
label: getPromptRoleLabel(role),
sourceOptions,
targetOptions,
source: selectedSource,
target: selectedTarget,
selected: selectedRoles.has(role),
applicable: Boolean(
selectedSource
&& selectedTarget
&& String(selectedSource.text || '').trim()
&& !selectedTarget.linked
),
};
});
const targetSelectionCounts = new Map();
for (const row of rows.filter(row => row.selected && row.applicable)) {
if (!row.target) continue;
const targetId = String(row.target.id);
targetSelectionCounts.set(targetId, (targetSelectionCounts.get(targetId) || 0) + 1);
}
return {
sourcePrompts,
targetPrompts,
rows,
selectedRoles,
selectedRoleCount: rows.filter(row => row.selected).length,
applicableSelectedRoleCount: rows.filter(row => row.selected && row.applicable).length,
unclassifiedSourceCount: sourcePrompts.filter(prompt => prompt.role === 'unknown').length,
unclassifiedTargetCount: targetPrompts.filter(prompt => prompt.role === 'unknown').length,
hasDuplicateTargetSelection: Array.from(targetSelectionCounts.values()).some(count => count > 1),
};
},
getMetadataPromptTransferRows(state = null) {
return this.getMetadataPromptTransferData(state).rows;
},
renderMetadataPromptTransferPanel(state) {
const imageState = state || getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(imageState);
const data = this.getMetadataPromptTransferData(imageState);
this._metadataPromptPreviewSignature = JSON.stringify(data.targetPrompts);
const sourceSummary = data.sourcePrompts.length
? `${data.sourcePrompts.length} editable prompt${data.sourcePrompts.length === 1 ? '' : 's'} found in the imported workflow.`
: imageState.loading
? 'Reading prompts from the imported workflow...'
: imageState.metadata?.workflow
? 'No editable prompt nodes found in the imported workflow.'
: 'Import an image or workflow JSON to transfer prompts.';
const targetSummary = data.targetPrompts.length
? `${data.targetPrompts.length} editable prompt field${data.targetPrompts.length === 1 ? '' : 's'} in the current workflow.`
: 'No editable prompt fields found in the current workflow.';
const hasApplicablePair = data.applicableSelectedRoleCount > 0;
const applyDisabled = promptTransfer.applying
|| !hasApplicablePair
|| data.hasDuplicateTargetSelection;
const roleRows = data.rows.map(row => {
const sourceOptions = row.sourceOptions.map(prompt => `
${this.escapeHtml(this.getMetadataPromptLabel(prompt))}
`).join('');
const targetOptions = row.targetOptions.map(prompt => `
${this.escapeHtml(this.getMetadataPromptLabel(prompt))}
`).join('');
const sourceRoleHint = row.source?.role === row.role
? 'Role detected from workflow connections.'
: 'Role not recognized automatically. Choose which imported prompt to use.';
const targetRoleHint = row.target?.role === row.role
? 'Positive/negative role detected from workflow connections.'
: 'Role not recognized automatically. Choose which text field to replace.';
const targetContextMenuAttrs = row.target
? this.getMetadataPromptNodeContextMenuAttrs?.(row.target) || ''
: '';
const sourceHtml = row.source
? `
${sourceOptions}
${this.escapeHtml(this.getMetadataPromptPreviewText(row.source) || '(empty)')}
${this.getMetadataPromptCopyButton(row.source)}
`
: 'No imported prompt available.
';
const targetHtml = row.target
? `
${targetOptions}
${this.escapeHtml(this.getMetadataPromptPreviewText(row.target) || '(empty)')}
${this.getMetadataPromptCopyButton(row.target)}
`
: 'No current text field available.
';
return `
→
${getSvgIcon('import')}
Imported
${this.getMetadataTooltipBadge(sourceRoleHint, `Imported ${row.role} prompt role information`)}
${sourceHtml}
`;
}).join('');
const unclassifiedNote = data.unclassifiedSourceCount || data.unclassifiedTargetCount
? 'Prompts without an obvious role can be chosen manually for either slot.'
: 'Positive and negative roles are detected automatically from node connections.';
const promptHelpText = [
sourceSummary,
targetSummary,
unclassifiedNote,
'Select which prompt roles to replace, then choose the source prompt and destination field.',
].join('\n\n');
const statusHtml = promptTransfer.error
? `${this.escapeHtml(promptTransfer.error)}
`
: '';
const isPromptCollapsed = this.isImageInspectorSectionCollapsed?.('prompt-transfer', imageState) || false;
return `
${statusHtml}
${roleRows || '
No positive or negative prompt slots are available.
'}
${this.escapeHtml(
data.hasDuplicateTargetSelection
? 'Choose a different current text field for each selected role.'
: !data.selectedRoleCount
? 'Select positive, negative, or both prompt roles.'
: hasApplicablePair
? `${data.applicableSelectedRoleCount} prompt role${data.applicableSelectedRoleCount === 1 ? '' : 's'} selected for replacement.`
: 'Prompt source and target selection needed.'
)}
${promptTransfer.applying
? 'Replacing... '
: 'Replace prompts'}
`;
},
refreshMetadataPromptPreview(workflow) {
const signature = JSON.stringify(extractWorkflowPrompts(workflow || {}).filter(prompt => !prompt.linked));
if (signature === this._metadataPromptPreviewSignature) return false;
return this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
},
renderMetadataPromptTransferPanelInPlace({ fallback = true } = {}) {
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
const currentPanel = result?.querySelector('.mr-image-prompt-transfer');
if (!currentPanel) {
if (fallback) this.renderImageMetadataResult();
return false;
}
currentPanel.outerHTML = this.renderMetadataPromptTransferPanel(getImageInspectorState(this));
this.bindTooltips?.(result);
return true;
},
async applyMetadataPromptTransfer() {
const state = getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(state);
if (promptTransfer.applying) return null;
const data = this.getMetadataPromptTransferData(state);
const pairs = data.rows.filter(row => row.selected && row.applicable);
if (!pairs.length) {
this.showNotification('Select at least one prompt role with an imported and current text field first.', 'warning');
return null;
}
if (data.hasDuplicateTargetSelection) {
this.showNotification('Choose a different current text field for each selected role.', 'warning');
return null;
}
const workflow = this.getCurrentWorkflow?.();
if (!workflow) {
this.showNotification('No current workflow is available.', 'warning');
return null;
}
const updatedWorkflow = cloneWorkflowSnapshot(workflow);
if (!updatedWorkflow) {
this.showNotification('Could not prepare the prompt replacement.', 'error');
return null;
}
let updatedCount = 0;
for (const pair of pairs) {
if (updateWorkflowPromptValue(updatedWorkflow, pair.target, pair.source.text)) {
updatedCount += 1;
}
}
if (!updatedCount) {
this.showNotification('The selected current text fields cannot be replaced.', 'warning');
return null;
}
promptTransfer.applying = true;
promptTransfer.error = '';
this.renderMetadataPromptTransferPanelInPlace();
try {
const applied = await this.updateWorkflowInComfyUI(updatedWorkflow, []);
if (!applied) throw new Error('ComfyUI could not apply the prompt replacement.');
promptTransfer.applying = false;
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
const currentWorkflow = this.getCurrentWorkflow?.() || updatedWorkflow;
this.activeWorkflowSignature = this.getWorkflowSignature?.(currentWorkflow) || null;
void this.openMetadataTransfer({ preserveContent: true });
this.showNotification(
`Replaced ${updatedCount} prompt field${updatedCount === 1 ? '' : 's'} in the current workflow.`,
'success',
);
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
return { workflow: updatedWorkflow, updated: updatedCount };
} catch (error) {
promptTransfer.applying = false;
promptTransfer.error = error?.message || 'The prompt replacement failed.';
this.renderMetadataPromptTransferPanelInPlace();
return null;
}
},
getMetadataTransferSourceModels(loadedModels = null) {
const models = Array.isArray(loadedModels?.loaded_models)
? loadedModels.loaded_models
: [];
return models
.map((model, sourceIndex) => ({ model, sourceIndex }))
.filter(({ model }) => (
model
&& !model.is_urn
&& (String(model.original_path || '').trim() || String(model.name || '').trim())
&& normalizeTransferCategory(model.category)
));
},
getMetadataTransferTargetGroups(loadedModels = null) {
const models = Array.isArray(loadedModels?.loaded_models)
? loadedModels.loaded_models
: [];
const categoryGroups = new Map();
for (const model of models) {
const rawWidgetIndex = model?.widget_index;
const widgetIndex = Number(rawWidgetIndex);
const hasWidgetIndex = (
Number.isInteger(rawWidgetIndex)
|| (
typeof rawWidgetIndex === 'string'
&& rawWidgetIndex.trim() !== ''
&& Number.isInteger(widgetIndex)
)
);
if (!model || model.is_urn || !hasWidgetIndex || widgetIndex < 0) continue;
const category = normalizeTransferCategory(model.category);
if (!category) continue;
let categoryGroup = categoryGroups.get(category);
if (!categoryGroup) {
categoryGroup = {
category,
nodes: new Map(),
};
categoryGroups.set(category, categoryGroup);
}
const nodeKey = getTransferNodeKey(model);
let node = categoryGroup.nodes.get(nodeKey);
if (!node) {
node = {
nodeKey,
nodeId: model.node_id,
nodeType: String(model.node_type || '').trim(),
nodeTitle: String(model.node_title || '').trim(),
subgraphId: String(model.subgraph_id || ''),
subgraphName: String(model.subgraph_name || '').trim(),
isTopLevel: model.is_top_level !== false,
active: false,
refs: [],
};
categoryGroup.nodes.set(nodeKey, node);
}
node.active = node.active || (model.active !== false && model.connected !== false);
node.refs.push({
...model,
widget_index: widgetIndex,
transferSlot: node.refs.length + 1,
transferKey: `${category}:${nodeKey}:${node.refs.length}`,
});
}
return Array.from(categoryGroups.values()).map(group => ({
category: group.category,
nodes: Array.from(group.nodes.values()),
}));
},
getMetadataTransferModelValue(model = {}) {
return String(
model?.original_path
|| model?.name
|| model?.filename
|| model?.resolved_path
|| model?.path
|| ''
).trim();
},
getMetadataTransferModelLabel(model = {}, fallback = 'Unknown model') {
const value = this.getMetadataTransferModelValue(model);
const filename = this.getFilenameFromPath?.(value) || value;
const label = this.stripModelExtension?.(filename) || filename;
const cleanLabel = String(label || fallback).trim() || fallback;
const friendlyName = String(model?.model_name || '').trim();
if (
friendlyName
&& friendlyName.toLowerCase() !== cleanLabel.toLowerCase()
) {
return `${cleanLabel} (${friendlyName})`;
}
return cleanLabel;
},
getMetadataTransferModelInteractionAttrs(model = {}, contextScope = 'loaded_model', label = '') {
if (!model || !isExistingResolvedModel(model)) return '';
let contextModel;
if (contextScope === 'local_model') {
contextModel = toResolverContextModel(model);
} else if (
contextScope === 'loaded_model'
&& typeof this.getLoadedModelContext === 'function'
) {
contextModel = this.getLoadedModelContext(model);
} else {
contextModel = {
...model,
context_scope: contextScope,
};
}
const contextMenuAttrs = typeof this.getContextMenuAttrs === 'function'
? this.getContextMenuAttrs(contextModel)
: '';
const previewTooltipAttrs = typeof this.getModelPreviewTooltipAttrs === 'function'
? this.getModelPreviewTooltipAttrs(model, label)
: '';
return `${contextMenuAttrs}${previewTooltipAttrs}`;
},
getMetadataTransferPreferences() {
return {
mode: normalizeTransferMode(safeStorage.getItem(METADATA_TRANSFER_MODE_STORAGE_KEY)),
activityScope: normalizeTransferActivityScope(
safeStorage.getItem(METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY)
),
};
},
setMetadataTransferMode(value) {
const mode = normalizeTransferMode(value);
safeStorage.setItem(METADATA_TRANSFER_MODE_STORAGE_KEY, mode);
return mode;
},
setMetadataTransferActivityScope(value) {
const activityScope = normalizeTransferActivityScope(value);
safeStorage.setItem(METADATA_TRANSFER_ACTIVITY_SCOPE_STORAGE_KEY, activityScope);
return activityScope;
},
getMetadataTransferUndoState() {
const state = getImageInspectorState(this);
const lastTransfer = state.lastTransfer;
if (!lastTransfer?.workflow) {
return {
hasHistory: false,
available: false,
reason: '',
};
}
if (lastTransfer.undoing) {
return {
hasHistory: true,
available: false,
reason: 'Undoing the last model transfer...',
};
}
const workflow = this.getCurrentWorkflow?.();
if (!workflow) {
return {
hasHistory: true,
available: false,
reason: 'No current workflow is available.',
};
}
const currentSignature = this.getWorkflowSignature?.(workflow) || '';
if (
lastTransfer.afterSignature
&& currentSignature
&& lastTransfer.afterSignature !== currentSignature
) {
return {
hasHistory: true,
available: false,
reason: 'The current workflow changed after the last transfer.',
};
}
return {
hasHistory: true,
available: true,
reason: 'Undo the last model transfer.',
};
},
getMetadataTransferSourceSelectionKey(ref = {}) {
return String(
ref.transferKey
|| `${normalizeTransferCategory(ref.category)}:${String(ref.node_id ?? '')}:${String(ref.widget_index ?? '')}`
);
},
getMetadataTransferSourceSelection(transfer, ref = {}) {
const key = this.getMetadataTransferSourceSelectionKey(ref);
const selections = transfer?.sourceSelections;
if (selections instanceof Map) return selections.get(key);
if (selections && typeof selections === 'object') return selections[key];
return null;
},
getMetadataTransferAcceptedFileTypes(target = {}) {
const ref = target?.ref || target || {};
const node = target?.node || {};
const widgetIndex = Number(ref.widget_index ?? ref.widgetIndex);
const resolution = {
node_id: ref.node_id ?? node.nodeId,
is_top_level: ref.is_top_level ?? node.isTopLevel,
subgraph_id: ref.subgraph_id || node.subgraphId || '',
};
const workflow = this.getCurrentWorkflow?.() || {};
const workflowNode = this.findWorkflowNodeForResolution?.(workflow, resolution);
const widgetName = String(
ref.widget_name
|| ref.widgetName
|| this.getWorkflowNodeWidgetName?.(workflowNode || {}, widgetIndex)
|| this.getGraphNodeWidgetName?.(workflowNode || {}, widgetIndex, resolution)
|| ''
).trim();
const explicitExtensions = (
ref.accepted_extensions
|| ref.allowed_extensions
|| ref.accepted_model_extensions
);
return this.getMissingAcceptedModelFileTypes?.({
node_type: ref.node_type || ref.nodeType || node.nodeType || '',
widget_index: Number.isInteger(widgetIndex) ? widgetIndex : -1,
widget_name: widgetName,
accepted_extensions: explicitExtensions,
}) || [];
},
getMetadataTransferCompatibleSourcesForTarget(target, sourcesByCategory) {
const category = normalizeTransferCategory(target?.category || target?.ref?.category);
const allSources = sourcesByCategory.get(category) || [];
if (!allSources.length) return [];
const activityScope = normalizeTransferActivityScope(target?.activityScope);
const activitySources = category === 'loras'
? allSources.filter(({ model }) => isTransferActivityAllowed(model?.active, activityScope))
: allSources;
if (!activitySources.length) return [];
const acceptedTypes = Array.isArray(target?.acceptedFileTypes)
? target.acceptedFileTypes
: this.getMetadataTransferAcceptedFileTypes(target);
const acceptedExtensions = new Set(
acceptedTypes
.map(type => String(type?.extension || '').trim().toLowerCase())
.filter(Boolean)
);
if (!acceptedExtensions.size) return activitySources;
return activitySources.filter(({ model }) => {
const modelValue = this.getMetadataTransferModelValue(model);
const fileType = this.getModelFileTypeInfo?.(modelValue);
return !fileType || acceptedExtensions.has(fileType.extension);
});
},
getMetadataTransferSourceForTarget(target, sourcesByCategory, transfer, fallbackIndex = 0) {
const category = normalizeTransferCategory(target?.category || target?.ref?.category);
const sources = Array.isArray(target?.compatibleSources)
? target.compatibleSources
: sourcesByCategory.get(category) || [];
if (!sources.length) return null;
const selectedSourceIndex = this.getMetadataTransferSourceSelection(transfer, target.ref);
if (selectedSourceIndex !== null && selectedSourceIndex !== undefined && selectedSourceIndex !== '') {
const selectedSource = sources.find(({ sourceIndex }) => (
String(sourceIndex) === String(selectedSourceIndex)
));
if (selectedSource) return selectedSource;
}
return sources.length === 1 ? sources[0] : sources[fallbackIndex] || null;
},
getMetadataTransferPreviewRows(state) {
const sourceEntries = this.getMetadataTransferSourceModels(state?.loadedModels);
const transfer = state?.transfer;
if (!transfer) return [];
const activityScope = normalizeTransferActivityScope(transfer.activityScope);
const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : [];
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
: new Set();
const selectedTargets = [];
const sourcesByCategory = new Map();
for (const sourceEntry of sourceEntries) {
const { model } = sourceEntry;
const category = normalizeTransferCategory(model.category);
if (!category) continue;
if (!sourcesByCategory.has(category)) sourcesByCategory.set(category, []);
sourcesByCategory.get(category).push(sourceEntry);
}
for (const group of targetGroups) {
const category = normalizeTransferCategory(group.category);
for (const node of group.nodes || []) {
if (!selectedNodeKeys.has(node.nodeKey)) continue;
for (const ref of node.refs || []) {
const target = { category, node, ref, activityScope };
target.acceptedFileTypes = this.getMetadataTransferAcceptedFileTypes(target);
target.compatibleSources = this.getMetadataTransferCompatibleSourcesForTarget(
target,
sourcesByCategory,
);
selectedTargets.push(target);
}
}
}
const makeRow = (
target,
nextSource = null,
{
operation = 'replace',
currentLabelOverride = '',
forceChange = false,
sourceSelectable = null,
unchangedWhenNoSource = false,
} = {},
) => {
const allSourceEntries = sourcesByCategory.get(target.category) || [];
const compatibleSources = Array.isArray(target.compatibleSources)
? target.compatibleSources
: this.getMetadataTransferCompatibleSourcesForTarget(target, sourcesByCategory);
const activityScope = normalizeTransferActivityScope(target.activityScope);
const acceptedFileTypes = Array.isArray(target.acceptedFileTypes)
? target.acceptedFileTypes
: this.getMetadataTransferAcceptedFileTypes(target);
const hasActivityMismatch = Boolean(
target.category === 'loras'
&& activityScope !== 'all'
&& allSourceEntries.length
&& !allSourceEntries.some(({ model }) => (
isTransferActivityAllowed(model?.active, activityScope)
))
);
const hasIncompatibleSources = Boolean(
allSourceEntries.length
&& acceptedFileTypes.length
&& !compatibleSources.length
) || hasActivityMismatch;
const sourceOptions = compatibleSources.map(({ model, sourceIndex }) => ({
sourceIndex,
label: this.getMetadataTransferModelLabel(model),
value: this.getMetadataTransferModelValue(model),
}));
const canSelectSource = sourceSelectable === null
? sourceOptions.length > 1
: Boolean(sourceSelectable) && sourceOptions.length > 1;
const nextModel = nextSource?.model || null;
const currentValue = this.getMetadataTransferModelValue(target.ref);
const currentLabel = currentLabelOverride
|| this.getMetadataTransferModelLabel(target.ref);
const nextValue = nextModel
? this.getMetadataTransferModelValue(nextModel)
: '';
const nextLabel = nextModel
? this.getMetadataTransferModelLabel(nextModel)
: hasActivityMismatch
? 'No matching activity'
: hasIncompatibleSources
? 'No compatible model'
: 'Unchanged';
const currentIdentity = normalizeTransferModelIdentity(currentValue);
const nextIdentity = normalizeTransferModelIdentity(nextValue);
const needsSourceSelection = canSelectSource && !nextModel;
const unchanged = !forceChange && !needsSourceSelection && (
!nextModel
? (
unchangedWhenNoSource
? !hasIncompatibleSources
: !hasIncompatibleSources && sourceOptions.length === 0
)
: (
currentIdentity && nextIdentity
? currentIdentity === nextIdentity
: currentLabel === nextLabel
)
);
const nodeName = target.node.nodeTitle
|| target.node.nodeType
|| `Node ${target.node.nodeId}`;
return {
category: target.category,
nodeKey: target.node.nodeKey,
refKey: this.getMetadataTransferSourceSelectionKey(target.ref),
nodeName,
nodeId: target.node.nodeId,
slot: target.ref?.transferSlot || 1,
ref: target.ref,
currentLabel,
currentValue,
nextLabel: unchanged
? 'Unchanged'
: needsSourceSelection
? 'Select model'
: nextLabel,
nextValue,
nextModel,
operation: unchanged ? 'unchanged' : operation,
unchanged,
sourceIndex: nextSource?.sourceIndex ?? null,
sourceOptions,
sourceSelectable: canSelectSource,
sourceSelectionKey: this.getMetadataTransferSourceSelectionKey(target.ref),
sourceUnavailable: hasIncompatibleSources,
sourceUnavailableReason: hasActivityMismatch
? 'No imported LoRA matches the active/inactive filter.'
: '',
acceptedFileTypes,
activityScope,
};
};
const selectedNodes = new Map();
for (const target of selectedTargets) {
let entry = selectedNodes.get(target.node.nodeKey);
if (!entry) {
entry = { node: target.node, targets: [] };
selectedNodes.set(target.node.nodeKey, entry);
}
entry.targets.push(target);
}
const mode = normalizeTransferMode(transfer.mode);
if (mode === 'merge') {
const previewRows = [];
for (const { node, targets } of selectedNodes.values()) {
const nodeType = String(node.nodeType || '');
const canAppendLoras = isAppendableLoraNodeType(nodeType);
const loraSources = this.getMetadataTransferCompatibleSourcesForTarget(
targets[0],
sourcesByCategory,
);
if (canAppendLoras && loraSources.length) {
for (const source of loraSources) {
previewRows.push(makeRow(
targets[0],
source,
{
operation: 'add',
currentLabelOverride: 'Existing LoRAs',
forceChange: true,
sourceSelectable: false,
},
));
}
continue;
}
for (const target of targets) {
previewRows.push(makeRow(target, null, {
operation: 'merge',
sourceSelectable: false,
}));
}
}
return previewRows;
}
if (mode === 'replace-add') {
const previewRows = [];
const sourceIndexes = new Map();
for (const { node, targets } of selectedNodes.values()) {
const nodeType = String(node.nodeType || '');
const loraTargets = targets.filter(target => target.category === 'loras');
if (isAppendableLoraNodeType(nodeType) && loraTargets.length) {
const loraSources = this.getMetadataTransferCompatibleSourcesForTarget(
loraTargets[0],
sourcesByCategory,
);
const usedSourceIndexes = new Set();
for (const [sourcePosition, target] of loraTargets.entries()) {
const source = this.getMetadataTransferSourceForTarget(
target,
sourcesByCategory,
transfer,
sourcePosition,
);
if (source) usedSourceIndexes.add(String(source.sourceIndex));
previewRows.push(makeRow(target, source, {
sourceSelectable: source ? null : false,
unchangedWhenNoSource: true,
}));
}
for (const source of loraSources) {
if (usedSourceIndexes.has(String(source.sourceIndex))) continue;
previewRows.push(makeRow(
loraTargets[0],
source,
{
operation: 'add',
currentLabelOverride: 'Remaining LoRAs',
forceChange: true,
sourceSelectable: false,
},
));
}
for (const target of targets) {
if (target.category === 'loras') continue;
const sourceIndex = sourceIndexes.get(target.category) || 0;
sourceIndexes.set(target.category, sourceIndex + 1);
const source = this.getMetadataTransferSourceForTarget(
target,
sourcesByCategory,
transfer,
sourceIndex,
);
previewRows.push(makeRow(target, source));
}
continue;
}
for (const target of targets) {
const sourceIndex = sourceIndexes.get(target.category) || 0;
sourceIndexes.set(target.category, sourceIndex + 1);
const source = this.getMetadataTransferSourceForTarget(
target,
sourcesByCategory,
transfer,
sourceIndex,
);
previewRows.push(makeRow(target, source));
}
}
return previewRows;
}
const sourceIndexes = new Map();
return selectedTargets.map(target => {
const sourceIndex = sourceIndexes.get(target.category) || 0;
sourceIndexes.set(target.category, sourceIndex + 1);
const source = this.getMetadataTransferSourceForTarget(
target,
sourcesByCategory,
transfer,
sourceIndex,
);
return makeRow(target, source);
});
},
renderMetadataTransferPreview(rows = []) {
const previewRows = Array.isArray(rows) ? rows : [];
return previewRows.map(row => {
const slotLabel = row.slot > 1 ? ` · Slot ${row.slot}` : '';
const targetLabel = `${row.nodeName} · Node ${row.nodeId}${slotLabel}`;
const currentTitle = row.currentValue || row.currentLabel;
const acceptedFormats = Array.isArray(row.acceptedFileTypes)
? row.acceptedFileTypes
.map(type => String(type?.display || '').trim())
.filter(Boolean)
.join(', ')
: '';
const nextTitle = row.sourceUnavailable
? row.sourceUnavailableReason
|| `No imported model matches this node's accepted format${acceptedFormats ? `: ${acceptedFormats}` : ''}.`
: row.nextValue || row.nextLabel;
const currentLabel = row.currentValue ? row.currentLabel : '(empty)';
const importedClass = row.sourceUnavailable
? 'is-placeholder'
: row.unchanged
? 'is-unchanged'
: row.operation === 'add'
? 'is-added'
: 'is-imported';
const currentModelAttrs = this.getMetadataTransferModelInteractionAttrs?.(
row.ref,
'loaded_model',
currentTitle,
) || '';
const importedModelAttrs = this.getMetadataTransferModelInteractionAttrs?.(
row.nextModel,
'local_model',
nextTitle,
) || '';
const sourceOptions = row.sourceSelectable
? [
`Select imported model `,
...row.sourceOptions.map(option => `
${this.escapeHtml(option.label)}
`),
].join('')
: '';
const importedValueHtml = row.sourceSelectable
? `${sourceOptions} `
: `${this.escapeHtml(row.nextLabel)} `;
return `
${getSvgIcon('workflow')}
Current
${this.escapeHtml(currentLabel)}
→
${getSvgIcon('import')}
Imported
${importedValueHtml}
`;
}).join('');
},
renderMetadataTransferPanel(state) {
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
const transfer = state?.transfer || {
loading: false,
applying: false,
undoing: false,
error: '',
mode: 'replace',
activityScope: 'all',
targetGroups: [],
selectedNodeKeys: new Set(),
};
const undoState = this.getMetadataTransferUndoState();
const undoButton = undoState.hasHistory ? `
${state.lastTransfer?.undoing ? 'Undoing...' : 'Undo transfer'}
` : '';
const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : [];
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
: new Set();
const sourceCategories = new Set(
sourceEntries
.map(({ model }) => normalizeTransferCategory(model.category))
);
const countText = (count, singular, plural = `${singular}s`) => (
`${count} ${count === 1 ? singular : plural}`
);
const sourceSummary = sourceEntries.length
? `${countText(sourceEntries.length, 'transferable model')} ready from the imported workflow.`
: state?.loading === 'models'
? 'Scanning the imported workflow for models...'
: state?.loading
? 'Reading source workflow metadata...'
: state?.metadata?.workflow
? 'No transferable model paths were found in the imported workflow.'
: state?.metadata
? 'The imported image has no embedded ComfyUI workflow.'
: 'Import a workflow with model metadata to enable Apply transfer.';
const isTransferCollapsed = this.isImageInspectorSectionCollapsed?.('model-transfer', state) || false;
if (transfer.loading) {
return `
Scanning the current workflow for target nodes...
${this.escapeHtml(sourceSummary)}
`;
}
if (transfer.error) {
return `
${this.escapeHtml(transfer.error)}
${this.escapeHtml(sourceSummary)}
`;
}
const previewRows = this.getMetadataTransferPreviewRows(state);
const previewRowsByNodeKey = new Map();
for (const row of previewRows) {
if (!previewRowsByNodeKey.has(row.nodeKey)) previewRowsByNodeKey.set(row.nodeKey, []);
previewRowsByNodeKey.get(row.nodeKey).push(row);
}
const collapsedCategories = transfer.collapsedCategories instanceof Set
? transfer.collapsedCategories
: new Set(transfer.collapsedCategories || []);
const categoryLabels = {
checkpoints: 'Checkpoints',
diffusion_models: 'Diffusion Models',
text_encoders: 'Text Encoders',
loras: 'LoRAs',
vae: 'VAE',
controlnet: 'ControlNet',
upscale_models: 'Upscale Models',
embeddings: 'Embeddings',
};
const getCategoryLabel = category => {
const normalized = normalizeTransferCategory(category);
if (categoryLabels[normalized]) return categoryLabels[normalized];
return String(this.getCategoryDisplayName(normalized) || normalized || 'Unknown')
.split('_')
.filter(Boolean)
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`)
.join(' ');
};
const renderUnselectedPreview = node => {
const refs = Array.isArray(node.refs) && node.refs.length ? node.refs : [{}];
return refs.map((ref, index) => {
const currentValue = this.getMetadataTransferModelValue(ref);
const currentLabel = currentValue
? this.getMetadataTransferModelLabel(ref)
: '(empty)';
const slotLabel = refs.length > 1 ? ` · Slot ${index + 1}` : '';
const currentModelAttrs = this.getMetadataTransferModelInteractionAttrs?.(
ref,
'loaded_model',
currentValue || currentLabel,
) || '';
return `
${getSvgIcon('workflow')}
Current${this.escapeHtml(slotLabel)}
${this.escapeHtml(currentLabel)}
→
${getSvgIcon('import')}
Imported
Select node
`;
}).join('');
};
const renderNode = node => {
const selected = selectedNodeKeys.has(node.nodeKey);
const scopeLabel = node.isTopLevel
? ''
: ` · ${node.subgraphName || node.subgraphId || 'subgraph'}`;
const nodeName = node.nodeTitle || node.nodeType || `Node ${node.nodeId}`;
const nodeType = node.nodeType && node.nodeType !== nodeName
? node.nodeType
: 'Workflow node';
const contextMenuAttrs = this.getContextMenuAttrs?.({
context_scope: 'workflow_node',
node_id: node.nodeId,
node_type: node.nodeType,
node_title: node.nodeTitle,
subgraph_id: node.subgraphId,
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 previewHtml = selected
? nodePreviewRows.length
? this.renderMetadataTransferPreview(nodePreviewRows)
: 'No imported LoRAs match the selected activity scope.
'
: renderUnselectedPreview(node);
return `
${selected ? '✓' : ''}
${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}
${this.escapeHtml(nodeType)} · ${countText(node.refs.length, 'model slot')} · ${node.active ? 'Active' : 'Inactive'}
${previewHtml}
`;
};
const targetRows = targetGroups.length
? targetGroups.map(group => {
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 categoryLabel = getCategoryLabel(group.category);
const collapsed = collapsedCategories.has(group.category);
return `
${collapsed ? '' : `${nodes.map(renderNode).join('')}
`}
`;
}).join('')
: 'No model nodes were found in the current workflow.
';
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 selectionSourceText = sourceEntries.length
? countText(sourceEntries.length, 'metadata model')
: 'No source workflow imported';
const hasUnresolvedSource = previewRows.some(row => (
row.sourceUnavailable
|| (row.sourceSelectable && (row.sourceIndex === null || row.sourceIndex === undefined))
));
const hasTransferableTarget = previewRows.length > 0;
const applyDisabled = (
transfer.applying
|| transfer.undoing
|| transfer.refreshing
|| !sourceEntries.length
|| !selectedTargetCount
|| !hasTransferableTarget
|| hasUnresolvedSource
);
const activityLabels = {
all: 'Active + inactive',
active: 'Active only',
inactive: 'Inactive only',
};
const transferHelpText = [
'Select a node row to include it in the transfer.',
sourceSummary,
sourceEntries.length
? 'Each row pairs a current slot with an imported model. If multiple models match a category, choose the one you want from the dropdown.'
: 'Target nodes come from the current ComfyUI workflow. Import a source workflow with model metadata to enable Apply transfer.',
activityScope !== 'all'
? `Imported LoRA filter: ${activityLabels[activityScope]}. Target slots in the workflow remain available.`
: '',
].filter(Boolean).join('\n\n');
return `
`;
},
renderMetadataTransferPanelInPlace({ fallback = true } = {}) {
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
const currentPanel = result?.querySelector('.mr-image-transfer');
if (!currentPanel) {
if (fallback) this.renderImageMetadataResult();
return false;
}
currentPanel.outerHTML = this.renderMetadataTransferPanel(getImageInspectorState(this));
this.bindTooltips?.(result);
return true;
},
async openMetadataTransfer({ force = false, preserveContent = false } = {}) {
const state = getImageInspectorState(this);
const imageRequestToken = state.requestToken;
const workflow = this.getCurrentWorkflow?.();
const workflowSignature = workflow
? (this.getWorkflowSignature?.(workflow) || '')
: '';
if (workflow) this.syncWorkflowScopedQueue?.(workflow);
const previousTransfer = state.transfer;
const keepCurrentTransferContent = Boolean(preserveContent && previousTransfer);
const preserveSelection = Boolean(
previousTransfer
&& (
preserveContent
|| (
workflowSignature
&& previousTransfer.workflowSignature
&& previousTransfer.workflowSignature === workflowSignature
)
)
);
const previousSourceSelections = previousTransfer?.sourceSelections;
const savedPreferences = this.getMetadataTransferPreferences();
const sourceSelections = preserveSelection
? previousSourceSelections instanceof Map
? Object.fromEntries(previousSourceSelections)
: previousSourceSelections && typeof previousSourceSelections === 'object'
? { ...previousSourceSelections }
: {}
: {};
const token = `transfer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
state.transfer = {
open: true,
loading: Boolean(workflow) && !keepCurrentTransferContent,
refreshing: Boolean(workflow) && keepCurrentTransferContent,
applying: false,
undoing: false,
error: '',
mode: normalizeTransferMode(previousTransfer?.mode ?? savedPreferences.mode),
activityScope: normalizeTransferActivityScope(
previousTransfer?.activityScope ?? savedPreferences.activityScope
),
requestToken: token,
workflowSignature,
targetGroups: [],
selectedNodeKeys: preserveSelection
? new Set(previousTransfer.selectedNodeKeys || [])
: new Set(),
collapsedCategories: preserveSelection
? new Set(previousTransfer.collapsedCategories || [])
: new Set(),
sourceSelections,
};
if (keepCurrentTransferContent) {
state.transfer.targetModels = previousTransfer.targetModels || null;
state.transfer.targetGroups = Array.isArray(previousTransfer.targetGroups)
? previousTransfer.targetGroups
: [];
}
this.renderMetadataTransferPanelInPlace({ fallback: !keepCurrentTransferContent });
this.renderMetadataPromptTransferPanelInPlace({ fallback: !keepCurrentTransferContent });
if (!workflow) {
state.transfer.loading = false;
state.transfer.error = 'No current workflow is available.';
this.renderMetadataTransferPanelInPlace();
return null;
}
try {
const cachedTargetModels = this.getCachedLoadedModelsForSignature?.(
workflowSignature,
{ force },
);
let targetModels = cachedTargetModels;
if (!targetModels) {
targetModels = await this.fetchJson(
'/model_resolver/loaded',
{
method: 'POST',
body: JSON.stringify({ workflow }),
},
'Scan current workflow for transfer'
);
}
if (
state.requestToken !== imageRequestToken
|| state.transfer?.requestToken !== token
) return null;
if (!cachedTargetModels && workflowSignature) {
this.cachedLoadedModelsSignature = workflowSignature;
this.cachedLoadedModelsData = targetModels;
this.saveLoadedModelsCacheForActiveWorkflow?.();
}
state.transfer.loading = false;
state.transfer.refreshing = false;
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.selectedNodeKeys = new Set(
Array.from(state.transfer.selectedNodeKeys || [])
.filter(nodeKey => targetNodeKeys.has(nodeKey))
);
this.renderMetadataTransferPanelInPlace();
this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
return targetModels;
} catch (error) {
if (
state.requestToken !== imageRequestToken
|| state.transfer?.requestToken !== token
) return null;
state.transfer.loading = false;
state.transfer.refreshing = false;
state.transfer.error = error?.message || 'Could not scan the current workflow.';
this.renderMetadataTransferPanelInPlace();
this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
return null;
}
},
clearMetadataTransferTargets() {
const state = getImageInspectorState(this);
if (!state.transfer) return;
state.transfer.selectedNodeKeys = new Set();
this.renderMetadataTransferPanelInPlace();
},
toggleMetadataTransferCategory(category) {
const state = getImageInspectorState(this);
if (!state.transfer || !category) return;
const normalizedCategory = normalizeTransferCategory(category);
const collapsed = state.transfer.collapsedCategories instanceof Set
? new Set(state.transfer.collapsedCategories)
: new Set(state.transfer.collapsedCategories || []);
if (collapsed.has(normalizedCategory)) collapsed.delete(normalizedCategory);
else collapsed.add(normalizedCategory);
state.transfer.collapsedCategories = collapsed;
this.renderMetadataTransferPanelInPlace();
},
toggleMetadataTransferNode(nodeKey) {
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;
const selected = new Set(state.transfer.selectedNodeKeys || []);
if (selected.has(String(nodeKey))) {
selected.delete(String(nodeKey));
} else {
selected.add(String(nodeKey));
}
state.transfer.selectedNodeKeys = selected;
this.renderMetadataTransferPanelInPlace();
},
async undoMetadataTransfer() {
const state = getImageInspectorState(this);
const lastTransfer = state.lastTransfer;
if (!lastTransfer?.workflow || lastTransfer.undoing) return null;
const undoState = this.getMetadataTransferUndoState();
if (!undoState.available) {
if (undoState.reason) this.showNotification(undoState.reason, 'warning');
this.renderMetadataTransferPanelInPlace();
return null;
}
const restoreWorkflow = cloneWorkflowSnapshot(lastTransfer.workflow);
if (!restoreWorkflow) {
this.showNotification('The previous workflow snapshot could not be restored.', 'error');
return null;
}
const transfer = state.transfer;
lastTransfer.undoing = true;
if (transfer) transfer.undoing = true;
this.renderMetadataTransferPanelInPlace();
try {
const restored = await this.updateWorkflowInComfyUI(restoreWorkflow, []);
if (!restored) {
throw new Error('ComfyUI could not restore the previous workflow.');
}
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
this.activeWorkflowSignature = lastTransfer.beforeSignature
|| this.getWorkflowSignature?.(restoreWorkflow)
|| null;
state.lastTransfer = null;
void this.openMetadataTransfer({ preserveContent: true });
this.showNotification('Undid the last model transfer.', 'success');
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
return restoreWorkflow;
} catch (error) {
if (state.lastTransfer === lastTransfer) lastTransfer.undoing = false;
if (state.transfer === transfer && transfer) {
transfer.undoing = false;
transfer.error = error?.message || 'The previous workflow could not be restored.';
}
this.renderMetadataTransferPanelInPlace();
return null;
}
},
async applyMetadataTransfer() {
const state = getImageInspectorState(this);
const transfer = state.transfer;
if (!transfer || transfer.loading || transfer.refreshing || transfer.applying || transfer.undoing) return null;
const mode = normalizeTransferMode(transfer.mode);
const activityScope = normalizeTransferActivityScope(transfer.activityScope);
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
const sourceModels = sourceEntries.map(({ model, sourceIndex }) => ({
...model,
source_index: sourceIndex,
}));
const sourcesByCategory = new Map();
for (const sourceEntry of sourceEntries) {
const category = normalizeTransferCategory(sourceEntry.model.category);
if (!category) continue;
if (!sourcesByCategory.has(category)) sourcesByCategory.set(category, []);
sourcesByCategory.get(category).push(sourceEntry);
}
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
: new Set();
const targetRefs = [];
const targetRefKeys = new Set();
const sourceIndexes = new Map();
const nodeSourceIndexes = new Map();
const appendLoraSourceIndexes = new Set();
let hasAppendableLoraTarget = false;
for (const group of transfer.targetGroups || []) {
const category = normalizeTransferCategory(group.category);
for (const node of group.nodes || []) {
if (!selectedNodeKeys.has(node.nodeKey)) continue;
for (const ref of node.refs || []) {
const refKey = ref.transferKey || this.getMetadataTransferSourceSelectionKey(ref);
if (targetRefKeys.has(refKey)) continue;
targetRefKeys.add(refKey);
const nodeType = String(node.nodeType || '');
const isAppendableLoraTarget = (
category === 'loras'
&& isAppendableLoraNodeType(nodeType)
&& (mode === 'merge' || mode === 'replace-add')
);
const sourceIndexKey = `${node.nodeKey}:${category}`;
const sourceIndex = mode === 'replace-add' && isAppendableLoraTarget
? nodeSourceIndexes.get(sourceIndexKey) || 0
: sourceIndexes.get(category) || 0;
if (mode === 'replace-add' && isAppendableLoraTarget) {
nodeSourceIndexes.set(sourceIndexKey, sourceIndex + 1);
} else {
sourceIndexes.set(category, sourceIndex + 1);
}
const target = { category, node, ref, activityScope };
target.acceptedFileTypes = this.getMetadataTransferAcceptedFileTypes(target);
target.compatibleSources = this.getMetadataTransferCompatibleSourcesForTarget(
target,
sourcesByCategory,
);
if (isAppendableLoraTarget) {
hasAppendableLoraTarget = true;
for (const source of target.compatibleSources) {
appendLoraSourceIndexes.add(String(source.sourceIndex));
}
}
const source = this.getMetadataTransferSourceForTarget(
target,
sourcesByCategory,
transfer,
sourceIndex,
);
targetRefs.push(source
? { ...ref, source_index: source.sourceIndex }
: ref);
}
}
}
const requestSourceModels = sourceModels.filter(model => {
const category = normalizeTransferCategory(model.category);
if (
category === 'loras'
&& !isTransferActivityAllowed(model.active, activityScope)
) return false;
return !hasAppendableLoraTarget
|| category !== 'loras'
|| appendLoraSourceIndexes.has(String(model.source_index));
});
const workflow = this.getCurrentWorkflow?.();
if (!sourceModels.length) {
this.showNotification('Import a workflow with transferable model metadata first.', 'warning');
return null;
}
if (!requestSourceModels.length) {
this.showNotification('No imported model matches the selected nodes\' accepted file formats.', 'warning');
return null;
}
if (!targetRefs.length) {
this.showNotification('Select at least one target node.', 'warning');
return null;
}
if (!workflow) {
this.showNotification('No current workflow is available.', 'warning');
return null;
}
const previousWorkflow = cloneWorkflowSnapshot(workflow);
if (!previousWorkflow) {
this.showNotification('Could not prepare an undo snapshot for this transfer.', 'error');
return null;
}
const previousWorkflowSignature = this.getWorkflowSignature?.(workflow) || null;
transfer.applying = true;
this.renderMetadataTransferPanelInPlace();
try {
const data = await this.fetchJson(
'/model_resolver/transfer-models',
{
method: 'POST',
body: JSON.stringify({
workflow,
mode,
activity_scope: activityScope,
source_models: requestSourceModels,
target_refs: targetRefs,
}),
},
'Transfer metadata models'
);
if (!data?.success || !data.workflow) {
throw new Error(data?.error || 'The metadata model transfer failed.');
}
const applied = await this.updateWorkflowInComfyUI(
data.workflow,
data.requires_full_reload ? [] : (data.resolutions || [])
);
if (!applied) {
throw new Error('ComfyUI could not apply the updated workflow.');
}
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
const updatedWorkflow = this.getCurrentWorkflow?.() || data.workflow;
state.lastTransfer = {
workflow: previousWorkflow,
beforeSignature: previousWorkflowSignature,
afterSignature: this.getWorkflowSignature?.(updatedWorkflow) || null,
undoing: false,
};
this.activeWorkflowSignature = this.getWorkflowSignature?.(updatedWorkflow) || null;
void this.openMetadataTransfer({ preserveContent: true });
const updatedCount = Number(data.updated) || 0;
const skippedCount = Array.isArray(data.skipped) ? data.skipped.length : 0;
const suffix = skippedCount
? ` ${skippedCount} selection${skippedCount === 1 ? '' : 's'} kept unchanged.`
: '';
this.showNotification(
`Transferred ${updatedCount} model${updatedCount === 1 ? '' : 's'} to the current workflow.${suffix}`,
skippedCount ? 'info' : 'success'
);
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
return data;
} catch (error) {
if (state.transfer) {
state.transfer.applying = false;
state.transfer.error = error?.message || 'The metadata model transfer failed.';
this.renderMetadataTransferPanelInPlace();
}
return null;
}
},
};