Improve image metadata transfer guidance and prompt candidate ordering
This commit is contained in:
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user