Improve image metadata transfer guidance and prompt candidate ordering

This commit is contained in:
Dariusz L
2026-09-17 00:48:00 +02:00
parent 2d6394be5c
commit 3829f95960
5 changed files with 185 additions and 16 deletions
+12
View File
@@ -214,6 +214,18 @@ test('metadata transfer shows a spinner while applying', () => {
assert.match(html, /<span>Applying\.\.\.<\/span>/);
});
test('metadata transfer guidance is available through a question tooltip', () => {
const context = createTransferContext();
const state = createTransferState([]);
state.loadedModels = null;
const html = context.renderMetadataTransferPanel(state);
assert.match(html, /class="mr-tooltip-badge"[^>]*data-tooltip="[\s\S]*Select a node row to include it in the transfer/);
assert.match(html, /Import a workflow with model metadata to enable Apply transfer/);
assert.doesNotMatch(html, /class="mr-image-transfer-note"/);
});
test('metadata transfer keeps target node checkboxes selectable without an imported workflow', () => {
const context = createTransferContext();
const state = createTransferState([]);
+57
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import { imageMetadataMethods } from '../web/resolver/views/tabs_image_metadata_methods.js';
import {
extractWorkflowPrompts,
getPromptCandidateScore,
sortPromptCandidates,
updateWorkflowPromptValue,
} from '../web/resolver/utils/prompt_utils.js';
@@ -83,6 +85,33 @@ test('workflow prompt extraction distinguishes positive and negative CLIP text n
);
});
test('prompt candidates are ranked by role and prompt-specific node type', () => {
const candidates = [
{
id: 'generic',
node_type: 'PrimitiveStringMultiline',
node_title: 'Text value',
widget_name: 'value',
role: 'unknown',
role_candidates: [],
},
{
id: 'clip',
node_type: 'CLIPTextEncode',
node_title: '',
widget_name: 'text',
role: 'positive',
role_candidates: ['positive'],
},
];
assert.ok(getPromptCandidateScore(candidates[1], 'positive') > getPromptCandidateScore(candidates[0], 'positive'));
assert.deepEqual(
sortPromptCandidates(candidates, 'positive').map(candidate => candidate.id),
['clip', 'generic'],
);
});
test('workflow prompt extraction inherits a role for a plain text node feeding CLIPTextEncode', () => {
const workflow = {
nodes: [
@@ -344,6 +373,8 @@ test('metadata prompt panel renders separate positive and negative transfer rows
assert.match(html, /data-image-prompt-target="negative"/);
assert.match(html, /data-image-prompt-role-selection="positive"[^>]*checked/);
assert.match(html, /data-image-prompt-role-selection="negative"[^>]*checked/);
assert.match(html, /class="mr-tooltip-badge"[^>]*data-tooltip="[\s\S]*Select which prompt roles to replace/);
assert.doesNotMatch(html, /class="mr-image-prompt-note"/);
const positiveTargetIndex = html.indexOf('data-image-prompt-target="positive"');
const positiveSourceIndex = html.indexOf('data-image-prompt-source="positive"');
assert.ok(positiveTargetIndex >= 0 && positiveTargetIndex < positiveSourceIndex);
@@ -355,6 +386,32 @@ test('metadata prompt panel renders separate positive and negative transfer rows
assert.match(html, /Replace prompts/);
});
test('metadata prompt selectors put the most likely prompt node first', () => {
const currentWorkflow = {
nodes: [
{
id: 90,
type: 'PrimitiveStringMultiline',
widgets_values: ['generic text field'],
},
clipTextNode(371, 'actual prompt', 11),
{
id: 3,
type: 'KSampler',
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 11 }],
},
],
};
const context = createContext(createRoleWorkflow(), currentWorkflow);
const positiveRow = context.getMetadataPromptTransferRows(context.imageInspectorState)
.find(row => row.role === 'positive');
assert.deepEqual(
positiveRow.targetOptions.map(prompt => prompt.node_id),
[371, 90],
);
});
test('metadata prompt role checkboxes stay selectable without an imported workflow', () => {
const context = createContext(null, createRoleWorkflow('current positive', 'current negative'));
context.imageInspectorState.metadata = null;
+22 -1
View File
@@ -2151,6 +2151,11 @@
color: #101010;
}
#model-resolver-modal [data-image-inspector-action="apply-prompts"].mr-btn-primary,
#model-resolver-modal [data-image-inspector-action="apply-prompts"].mr-btn-primary:hover:not(:disabled) {
color: #101010;
}
.mr-btn-secondary,
.mr-btn-link,
#model-resolver-modal .mr-btn-secondary,
@@ -4233,6 +4238,10 @@
margin: 0;
}
.mr-image-inspector .mr-loaded-models-title {
font-size: var(--mr-font-xl);
}
.mr-loaded-total {
display: inline-flex;
align-items: center;
@@ -4972,7 +4981,19 @@ button.mr-info-link:disabled {
}
.mr-image-prompt-row.is-selected {
background: rgba(114, 211, 138, 0.055);
position: relative;
background: linear-gradient(90deg, rgba(114, 211, 138, 0.12), rgba(114, 211, 138, 0.035) 55%, transparent);
}
.mr-image-prompt-row.is-selected::before {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--mr-accent);
content: "";
}
.mr-image-prompt-row-header {
+51
View File
@@ -18,6 +18,11 @@ const PROMPT_WIDGET_NAMES = new Set([
]);
const PROMPT_NODE_TYPE_PATTERN = /(?:clip.*text|text.*encode|primitive.*string|string.*multiline|text.*multiline|multiline.*text|conditioning.*text|textinput)/i;
const PROMPT_NODE_TYPE_SCORE_RULES = [
{ pattern: /(?:clip.*text|text.*encode)/i, score: 500 },
{ pattern: /conditioning.*text/i, score: 420 },
{ pattern: /(?:primitive.*string|string.*multiline|text.*multiline|multiline.*text|textinput)/i, score: 320 },
];
const SCALAR_WIDGET_TYPES = new Set(['STRING', 'INT', 'FLOAT', 'BOOLEAN']);
const PROMPT_INPUT_NAMES = new Set(['text', 'prompt', 'text_input', 'string']);
const TEXT_WIDGET_TYPES = new Set(['STRING', 'TEXT']);
@@ -430,6 +435,52 @@ function isPromptTextDescriptor(descriptor = {}, { node = {}, hasGraphPromptRole
));
}
function getPromptLabelScore(value, role = '') {
const label = String(value ?? '').trim();
const token = normalizePromptName(label);
if (!token) return 0;
let score = 0;
if (role && getRoleFromLabel(label) === role) score += 180;
if (token.includes('prompt')) score += 110;
if (token === 'text' || token.includes('text')) score += 35;
return score;
}
function getPromptNodeTypeScore(value) {
const nodeType = String(value ?? '').trim();
return PROMPT_NODE_TYPE_SCORE_RULES.find(rule => rule.pattern.test(nodeType))?.score || 0;
}
export function getPromptCandidateScore(prompt = {}, role = '') {
const requestedRole = String(role || '').trim();
const roleCandidates = Array.isArray(prompt.role_candidates)
? prompt.role_candidates
: [];
let score = 0;
if (requestedRole && prompt.role === requestedRole) score += 1000;
else if (requestedRole && roleCandidates.includes(requestedRole)) score += 650;
score += getPromptNodeTypeScore(prompt.node_type);
score += getPromptLabelScore(prompt.node_title, requestedRole);
score += getPromptLabelScore(prompt.widget_name, requestedRole);
return score;
}
export function sortPromptCandidates(prompts = [], role = '') {
if (!Array.isArray(prompts)) return [];
return prompts
.map((prompt, index) => ({
prompt,
index,
score: getPromptCandidateScore(prompt, role),
}))
.sort((left, right) => right.score - left.score || left.index - right.index)
.map(({ prompt }) => prompt);
}
function promptIdentity(entry, descriptor) {
return `${entry.graphKey}:${String(entry.node.id)}:${descriptor.index}:${normalizePromptName(descriptor.name)}`;
}
@@ -7,6 +7,7 @@ import { normalizeCategoryValue } from "../utils/category_utils.js";
import {
extractWorkflowPrompts,
getPromptRoleLabel,
sortPromptCandidates,
updateWorkflowPromptValue,
} from "../utils/prompt_utils.js";
@@ -202,8 +203,7 @@ export const imageMetadataMethods = {
<div class="mr-image-inspector">
<div class="mr-loaded-models-header mr-image-inspector-header">
<div class="mr-loaded-title-block">
<h3 class="mr-loaded-models-title">Image Metadata</h3>
<p class="mr-loaded-models-subtitle">Paste JSON or drop a supported file anywhere in this panel.</p>
<h3 class="mr-loaded-models-title">Image Metadata ${this.getMetadataTooltipBadge('Paste JSON or drop a supported file anywhere in this panel.', 'Image metadata help')}</h3>
</div>
<div class="mr-image-inspector-header-actions">
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear">Clear</button>
@@ -225,6 +225,7 @@ export const imageMetadataMethods = {
this.bindImageMetadataEvents();
}
this.bindTooltips?.(this.contentElement);
this.renderImageMetadataResult();
},
@@ -725,6 +726,13 @@ export const imageMetadataMethods = {
return `${text.slice(0, MAX_PROMPT_PREVIEW_LENGTH)}\n…`;
},
getMetadataTooltipBadge(text, ariaLabel = 'More information') {
const tooltip = String(text ?? '').trim();
if (!tooltip) return '';
const label = String(ariaLabel ?? 'More information').trim() || 'More information';
return `<span class="mr-tooltip-badge" data-tooltip="${this.escapeHtml(tooltip)}" aria-label="${this.escapeHtml(label)}">?</span>`;
},
getMetadataPromptTransferData(state = null) {
const imageState = state || getImageInspectorState(this);
const promptTransfer = this.getMetadataPromptTransferState(imageState);
@@ -739,12 +747,18 @@ export const imageMetadataMethods = {
const usedTargetIds = new Set();
const rows = METADATA_PROMPT_ROLES.map(role => {
const sourceOptions = sourcePrompts.filter(prompt => (
prompt.role === role || prompt.role === 'unknown'
));
const targetOptions = targetPrompts.filter(prompt => (
prompt.role === role || prompt.role === 'unknown'
));
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] || '')
));
@@ -886,6 +900,13 @@ export const imageMetadataMethods = {
const unclassifiedNote = data.unclassifiedSourceCount || data.unclassifiedTargetCount
? 'Some prompts have no explicit role in the graph and remain selectable in both slots.'
: 'Positive and negative roles are detected from connected node input names whenever available.';
const promptHelpText = [
sourceSummary,
targetSummary,
unclassifiedNote,
'Select which prompt roles to replace, then choose the source and target text fields.',
'Select an imported prompt and a current text field.',
].join('\n\n');
const statusHtml = promptTransfer.error
? `<p class="mr-error-text">${this.escapeHtml(promptTransfer.error)}</p>`
: '';
@@ -894,12 +915,10 @@ export const imageMetadataMethods = {
<section class="mr-image-prompt-transfer">
<div class="mr-image-prompt-header">
<div>
<h3 class="mr-loaded-models-title">Transfer prompts to current workflow</h3>
<p class="mr-loaded-models-subtitle">${this.escapeHtml(sourceSummary)} ${this.escapeHtml(targetSummary)}</p>
<h3 class="mr-loaded-models-title">Transfer prompts to current workflow ${this.getMetadataTooltipBadge(promptHelpText, 'Prompt transfer help')}</h3>
</div>
</div>
${statusHtml}
<p class="mr-image-prompt-note">${this.escapeHtml(unclassifiedNote)} Select which prompt roles to replace, then choose the source and target text fields.</p>
<div class="mr-image-prompt-list">
${roleRows || '<p class="mr-image-prompt-empty">No positive or negative prompt slots are available.</p>'}
</div>
@@ -911,7 +930,7 @@ export const imageMetadataMethods = {
? 'Select positive, negative, or both prompt roles.'
: hasApplicablePair
? `${data.applicableSelectedRoleCount} prompt role${data.applicableSelectedRoleCount === 1 ? '' : 's'} selected for replacement.`
: 'Select an imported prompt and a current text field.'
: 'Prompt source and target selection needed.'
)}</span>
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="apply-prompts" ${applyDisabled ? 'disabled' : ''} aria-busy="${promptTransfer.applying ? 'true' : 'false'}">
${promptTransfer.applying
@@ -1828,12 +1847,22 @@ export const imageMetadataMethods = {
active: 'Active only',
inactive: 'Inactive only',
};
const transferHelpText = [
'Select the target nodes.',
'Select a node row to include it in the transfer.',
sourceSummary,
sourceEntries.length
? 'The current and imported models are shown in the same row. Choose a different imported model when a category contains multiple models.'
: 'Target nodes come from the current ComfyUI workflow. Import a source workflow with model metadata to enable Apply transfer.',
activityScope !== 'all'
? `Imported LoRAs: ${activityLabels[activityScope]}; current target slots remain eligible.`
: '',
].filter(Boolean).join('\n\n');
return `
<section class="mr-image-transfer">
<div class="mr-image-transfer-header">
<div>
<h3 class="mr-loaded-models-title">Transfer models to current workflow <span class="mr-loaded-total">${selectedTargetCount}/${allTargetNodeKeys.size}</span></h3>
<p class="mr-loaded-models-subtitle">Select the target nodes. ${this.escapeHtml(sourceSummary)}</p>
<h3 class="mr-loaded-models-title">Transfer models to current workflow <span class="mr-loaded-total">${selectedTargetCount}/${allTargetNodeKeys.size}</span>${this.getMetadataTooltipBadge(transferHelpText, 'Model transfer help')}</h3>
</div>
<div class="mr-image-inspector-header-actions">
${undoButton}
@@ -1863,7 +1892,6 @@ export const imageMetadataMethods = {
</label>
`).join('')}
</div>
<p class="mr-image-transfer-note">Select a node row to include it in the transfer. ${this.escapeHtml(sourceEntries.length ? 'The current and imported models are shown in the same row. Choose a different imported model when a category contains multiple models.' : 'Target nodes come from the current ComfyUI workflow. Import a source workflow with model metadata to enable Apply transfer.')}${activityScope !== 'all' ? ` ${this.escapeHtml(`Imported LoRAs: ${activityLabels[activityScope]}; current target slots remain eligible.`)}` : ''}</p>
<div class="mr-image-transfer-category-list">${targetRows}</div>
<div class="mr-image-transfer-actions">
<span class="mr-image-transfer-selection-count">${this.escapeHtml(selectionSourceText)} · ${selectedTargetCount} selected node${selectedTargetCount === 1 ? '' : 's'}</span>