Add image metadata prompt transfer and keep transfer nodes selectable
This commit is contained in:
@@ -214,6 +214,41 @@ test('metadata transfer shows a spinner while applying', () => {
|
||||
assert.match(html, /<span>Applying\.\.\.<\/span>/);
|
||||
});
|
||||
|
||||
test('metadata transfer keeps target node checkboxes selectable without an imported workflow', () => {
|
||||
const context = createTransferContext();
|
||||
const state = createTransferState([]);
|
||||
state.loadedModels = null;
|
||||
state.transfer.selectedNodeKeys = new Set();
|
||||
let renderCount = 0;
|
||||
context.imageInspectorState = state;
|
||||
context.renderMetadataTransferPanelInPlace = () => {
|
||||
renderCount += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
const html = context.renderMetadataTransferPanel(state);
|
||||
|
||||
assert.match(html, /data-image-transfer-node="top:1"/);
|
||||
const targetButton = html.match(/<button[^>]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || '';
|
||||
assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/);
|
||||
context.toggleMetadataTransferNode('top:1');
|
||||
assert.deepEqual(Array.from(state.transfer.selectedNodeKeys), ['top:1']);
|
||||
assert.equal(renderCount, 1);
|
||||
});
|
||||
|
||||
test('metadata transfer does not disable a target category missing from the imported workflow', () => {
|
||||
const context = createTransferContext();
|
||||
const state = createTransferState([
|
||||
{ category: 'checkpoints', original_path: 'imported-checkpoint.safetensors' },
|
||||
]);
|
||||
|
||||
const html = context.renderMetadataTransferPanel(state);
|
||||
|
||||
assert.match(html, /No source model/);
|
||||
const targetButton = html.match(/<button[^>]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || '';
|
||||
assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/);
|
||||
});
|
||||
|
||||
test('metadata transfer exposes Undo transfer while the post-transfer workflow is unchanged', () => {
|
||||
const context = createTransferContext();
|
||||
const state = createTransferState([
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { imageMetadataMethods } from '../web/resolver/views/tabs_image_metadata_methods.js';
|
||||
import {
|
||||
extractWorkflowPrompts,
|
||||
updateWorkflowPromptValue,
|
||||
} from '../web/resolver/utils/prompt_utils.js';
|
||||
|
||||
function clipTextNode(id, text, outputLink, { title = '', textLink = null } = {}) {
|
||||
return {
|
||||
id,
|
||||
type: 'CLIPTextEncode',
|
||||
title,
|
||||
widgets_values: [text],
|
||||
inputs: [
|
||||
{ name: 'clip', type: 'CLIP', link: 1 },
|
||||
{ name: 'text', type: 'STRING', widget: { name: 'text' }, link: textLink },
|
||||
],
|
||||
outputs: [{ links: [outputLink] }],
|
||||
};
|
||||
}
|
||||
|
||||
function createRoleWorkflow(positiveText = 'positive source', negativeText = 'negative source') {
|
||||
return {
|
||||
nodes: [
|
||||
clipTextNode(1, positiveText, 11),
|
||||
clipTextNode(2, negativeText, 12),
|
||||
{
|
||||
id: 3,
|
||||
type: 'KSampler',
|
||||
inputs: [
|
||||
{ name: 'positive', type: 'CONDITIONING', link: 11 },
|
||||
{ name: 'negative', type: 'CONDITIONING', link: 12 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(sourceWorkflow, currentWorkflow) {
|
||||
const state = {
|
||||
metadata: { workflow: sourceWorkflow },
|
||||
promptTransfer: null,
|
||||
};
|
||||
const notifications = [];
|
||||
let workflow = currentWorkflow;
|
||||
return {
|
||||
...imageMetadataMethods,
|
||||
imageInspectorState: state,
|
||||
getCurrentWorkflow: () => workflow,
|
||||
getWorkflowSignature: value => JSON.stringify(value),
|
||||
updateWorkflowInComfyUI: async value => {
|
||||
workflow = value;
|
||||
return true;
|
||||
},
|
||||
openMetadataTransfer() {},
|
||||
renderMetadataPromptTransferPanelInPlace() {},
|
||||
escapeHtml: value => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '''),
|
||||
showNotification(message, type) {
|
||||
notifications.push({ message, type });
|
||||
},
|
||||
getWorkflow() {
|
||||
return workflow;
|
||||
},
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
|
||||
test('workflow prompt extraction distinguishes positive and negative CLIP text nodes', () => {
|
||||
const prompts = extractWorkflowPrompts(createRoleWorkflow());
|
||||
|
||||
assert.deepEqual(
|
||||
prompts.map(prompt => [prompt.node_id, prompt.text, prompt.role, prompt.widget_name]),
|
||||
[
|
||||
[1, 'positive source', 'positive', 'text'],
|
||||
[2, 'negative source', 'negative', 'text'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prompt extraction inherits a role for a plain text node feeding CLIPTextEncode', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'PrimitiveStringMultiline',
|
||||
widgets_values: ['plain positive'],
|
||||
outputs: [{ links: [20] }],
|
||||
},
|
||||
clipTextNode(11, 'stale value', 30, { textLink: 20 }),
|
||||
{
|
||||
id: 12,
|
||||
type: 'KSampler',
|
||||
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 30 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const prompts = extractWorkflowPrompts(workflow);
|
||||
|
||||
assert.equal(prompts.find(prompt => prompt.node_id === 10)?.role, 'positive');
|
||||
assert.equal(prompts.find(prompt => prompt.node_id === 10)?.text, 'plain positive');
|
||||
assert.equal(prompts.find(prompt => prompt.node_id === 11)?.linked, true);
|
||||
});
|
||||
|
||||
test('workflow prompt extraction recognizes generic text nodes through prompt-node links', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 20,
|
||||
type: 'StringLiteral',
|
||||
widgets_values: ['generic positive'],
|
||||
outputs: [{ links: [40] }],
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
type: 'CLIPTextEncodeSD3',
|
||||
widgets_values: ['stale value'],
|
||||
inputs: [{ name: 'clip_l', type: 'STRING', widget: { name: 'clip_l' }, link: 40 }],
|
||||
outputs: [{ links: [41] }],
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'KSampler',
|
||||
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 41 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const prompt = extractWorkflowPrompts(workflow).find(item => item.node_id === 20);
|
||||
|
||||
assert.equal(prompt?.text, 'generic positive');
|
||||
assert.equal(prompt?.role, 'positive');
|
||||
});
|
||||
|
||||
test('workflow prompt extraction ignores list and control widgets on prompt-named nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 30,
|
||||
type: 'Prompt enhancement seed',
|
||||
title: 'Prompt enhancement seed (if used)',
|
||||
widgets: [
|
||||
{ name: 'value', type: 'INT' },
|
||||
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
|
||||
],
|
||||
widgets_values: [1, 'fixed'],
|
||||
inputs: [
|
||||
{ name: 'value', type: 'INT' },
|
||||
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
type: 'Prompt enhancement seed',
|
||||
title: 'Prompt enhancement seed (if used)',
|
||||
widgets_values: [1, 'fixed'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(extractWorkflowPrompts(workflow), []);
|
||||
});
|
||||
|
||||
test('workflow prompt extraction ignores filename fields on image output nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 371,
|
||||
type: 'CLIPTextEncode',
|
||||
title: 'Prompt',
|
||||
widgets_values: ['This is prompt positive'],
|
||||
outputs: [{ name: 'CONDITIONING', type: 'CONDITIONING', links: [584] }],
|
||||
},
|
||||
{
|
||||
id: 594,
|
||||
type: 'SaveImage',
|
||||
title: 'Almost done',
|
||||
widgets: [{ name: 'filename_prefix', type: 'STRING', value: 'ComfyUI' }],
|
||||
widgets_values: ['ComfyUI'],
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 942 }],
|
||||
outputs: [{ name: 'images', type: 'IMAGE', links: null }],
|
||||
},
|
||||
{
|
||||
id: 596,
|
||||
type: 'SaveImage',
|
||||
title: 'Your Porn',
|
||||
widgets: [{ name: 'filename_prefix', type: 'STRING', value: 'ComfyUI' }],
|
||||
widgets_values: ['ComfyUI'],
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 945 }],
|
||||
outputs: [{ name: 'images', type: 'IMAGE', links: null }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
extractWorkflowPrompts(workflow).map(prompt => prompt.node_id),
|
||||
[371],
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prompt extraction tolerates sparse widget value arrays', () => {
|
||||
const widgetsValues = new Array(2);
|
||||
widgetsValues[1] = 'sparse prompt';
|
||||
const workflow = {
|
||||
nodes: [{
|
||||
id: 32,
|
||||
type: 'PrimitiveStringMultiline',
|
||||
widgets_values: widgetsValues,
|
||||
}],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
extractWorkflowPrompts(workflow).map(prompt => [prompt.node_id, prompt.widget_index, prompt.text]),
|
||||
[[32, 1, 'sparse prompt']],
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prompt extraction follows serialized positive links and leaves negative optional', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 40,
|
||||
type: 'PrimitiveStringMultiline',
|
||||
widgets_values: ['serialized positive'],
|
||||
},
|
||||
{
|
||||
id: 41,
|
||||
type: 'CLIPTextEncode',
|
||||
widgets_values: ['stale value'],
|
||||
inputs: [{ name: 'text', type: 'STRING', widget: { name: 'text' }, link: 401 }],
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
type: 'KSampler',
|
||||
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 402 }],
|
||||
},
|
||||
],
|
||||
links: [
|
||||
[401, 40, 0, 41, 0, 'STRING'],
|
||||
[402, 41, 0, 42, 0, 'CONDITIONING'],
|
||||
],
|
||||
};
|
||||
|
||||
const prompts = extractWorkflowPrompts(workflow);
|
||||
|
||||
assert.equal(prompts.find(prompt => prompt.node_id === 40)?.role, 'positive');
|
||||
assert.equal(prompts.some(prompt => prompt.role === 'negative'), false);
|
||||
});
|
||||
|
||||
test('workflow prompt traversal follows text inputs and skips unrelated seed branches', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 50,
|
||||
type: 'StringLiteral',
|
||||
widgets_values: ['connected positive'],
|
||||
outputs: [{ links: [501] }],
|
||||
},
|
||||
{
|
||||
id: 51,
|
||||
type: 'PromptEnhancer',
|
||||
widgets: [
|
||||
{ name: 'text', type: 'STRING' },
|
||||
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
|
||||
],
|
||||
widgets_values: ['enhanced positive', 'fixed'],
|
||||
inputs: [
|
||||
{ name: 'text', type: 'STRING', link: 501 },
|
||||
{ name: 'seed', type: 'INT', link: 502 },
|
||||
],
|
||||
outputs: [{ links: [503] }],
|
||||
},
|
||||
{
|
||||
id: 52,
|
||||
type: 'Prompt enhancement seed',
|
||||
widgets: [
|
||||
{ name: 'value', type: 'INT' },
|
||||
{ name: 'control_after_generate', type: 'COMBO', options: ['fixed', 'increment'] },
|
||||
],
|
||||
widgets_values: [1, 'fixed'],
|
||||
outputs: [{ links: [502] }],
|
||||
},
|
||||
{
|
||||
id: 53,
|
||||
type: 'KSampler',
|
||||
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 503 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const prompts = extractWorkflowPrompts(workflow);
|
||||
|
||||
assert.deepEqual(
|
||||
prompts.map(prompt => [prompt.node_id, prompt.text, prompt.role]),
|
||||
[
|
||||
[50, 'connected positive', 'positive'],
|
||||
[51, 'enhanced positive', 'positive'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow prompt replacement updates the serialized text widget and named value', () => {
|
||||
const workflow = createRoleWorkflow();
|
||||
workflow.nodes[0].widgets_values_named = { text: 'positive source' };
|
||||
const prompt = extractWorkflowPrompts(workflow).find(item => item.role === 'positive');
|
||||
|
||||
assert.equal(updateWorkflowPromptValue(workflow, prompt, 'replacement'), true);
|
||||
assert.equal(workflow.nodes[0].widgets_values[0], 'replacement');
|
||||
assert.equal(workflow.nodes[0].widgets_values_named.text, 'replacement');
|
||||
});
|
||||
|
||||
test('linked prompt inputs are not treated as replaceable text fields', () => {
|
||||
const workflow = createRoleWorkflow('linked value');
|
||||
workflow.nodes[0].inputs[1].link = 99;
|
||||
const prompt = extractWorkflowPrompts(workflow).find(item => item.node_id === 1);
|
||||
|
||||
assert.equal(prompt.linked, true);
|
||||
assert.equal(updateWorkflowPromptValue(workflow, prompt, 'replacement'), false);
|
||||
assert.equal(workflow.nodes[0].widgets_values[0], 'linked value');
|
||||
});
|
||||
|
||||
test('metadata prompt panel renders separate positive and negative transfer rows', () => {
|
||||
const context = createContext(createRoleWorkflow(), createRoleWorkflow('current positive', 'current negative'));
|
||||
const contextMenuCalls = [];
|
||||
context.getContextMenuAttrs = (model, tooltip) => {
|
||||
contextMenuCalls.push({ model, tooltip });
|
||||
return ` data-context-scope="${model.context_scope}" data-node-id="${model.node_id}"`;
|
||||
};
|
||||
const rows = context.getMetadataPromptTransferRows(context.imageInspectorState);
|
||||
const html = context.renderMetadataPromptTransferPanel(context.imageInspectorState);
|
||||
|
||||
assert.deepEqual(rows.map(row => row.role), ['positive', 'negative']);
|
||||
assert.equal(rows[0].source.text, 'positive source');
|
||||
assert.equal(rows[1].source.text, 'negative source');
|
||||
assert.match(html, /Positive prompt/);
|
||||
assert.match(html, /Negative prompt/);
|
||||
assert.match(html, /data-image-prompt-source="positive"/);
|
||||
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/);
|
||||
const positiveTargetIndex = html.indexOf('data-image-prompt-target="positive"');
|
||||
const positiveSourceIndex = html.indexOf('data-image-prompt-source="positive"');
|
||||
assert.ok(positiveTargetIndex >= 0 && positiveTargetIndex < positiveSourceIndex);
|
||||
assert.deepEqual(
|
||||
contextMenuCalls.map(call => [call.model.context_scope, call.model.node_id]),
|
||||
[['workflow_node', 1], ['workflow_node', 2]],
|
||||
);
|
||||
assert.match(html, /class="mr-image-prompt-side is-context-menu" data-context-scope="workflow_node" data-node-id="1"/);
|
||||
assert.match(html, /Replace prompts/);
|
||||
});
|
||||
|
||||
test('metadata prompt role checkboxes stay selectable without an imported workflow', () => {
|
||||
const context = createContext(null, createRoleWorkflow('current positive', 'current negative'));
|
||||
context.imageInspectorState.metadata = null;
|
||||
|
||||
const html = context.renderMetadataPromptTransferPanel(context.imageInspectorState);
|
||||
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const checkbox = html.match(new RegExp(`<input type="checkbox" data-image-prompt-role-selection="${role}"[^>]*>`))?.[0] || '';
|
||||
assert.match(checkbox, /type="checkbox"/);
|
||||
assert.doesNotMatch(checkbox, /disabled/);
|
||||
}
|
||||
});
|
||||
|
||||
test('metadata prompt role selection survives importing a source workflow', async () => {
|
||||
const context = createContext(null, createRoleWorkflow('current positive', 'current negative'));
|
||||
const promptTransfer = context.getMetadataPromptTransferState(context.imageInspectorState);
|
||||
promptTransfer.selectedRoles = new Set(['positive']);
|
||||
promptTransfer.sourceSelections = { positive: 'old-source' };
|
||||
context.fetchJson = async (_endpoint, options) => {
|
||||
const payload = JSON.parse(options.body);
|
||||
if (payload.source_type === 'workflow') {
|
||||
return { workflow: createRoleWorkflow('imported positive', 'imported negative') };
|
||||
}
|
||||
return { loaded_models: [], total: 0 };
|
||||
};
|
||||
context.renderImageMetadataResult = () => {};
|
||||
|
||||
await context.runImageMetadataInspection({
|
||||
source_type: 'workflow',
|
||||
filename: 'source.json',
|
||||
});
|
||||
|
||||
assert.deepEqual(Array.from(context.imageInspectorState.promptTransfer.selectedRoles), ['positive']);
|
||||
assert.deepEqual(context.imageInspectorState.promptTransfer.sourceSelections, {});
|
||||
});
|
||||
|
||||
test('metadata prompt transfer applies only the selected prompt role', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
try {
|
||||
for (const [selectedRole, replacedNodeIndex, untouchedNodeIndex] of [
|
||||
['positive', 0, 1],
|
||||
['negative', 1, 0],
|
||||
]) {
|
||||
const context = createContext(
|
||||
createRoleWorkflow('imported positive', 'imported negative'),
|
||||
createRoleWorkflow('current positive', 'current negative'),
|
||||
);
|
||||
const state = context.getMetadataPromptTransferState(context.imageInspectorState);
|
||||
state.selectedRoles = new Set([selectedRole]);
|
||||
const html = context.renderMetadataPromptTransferPanel(context.imageInspectorState);
|
||||
const selectedCheckbox = html.match(new RegExp(`<input type="checkbox" data-image-prompt-role-selection="${selectedRole}"[^>]*>`))?.[0] || '';
|
||||
const otherRole = selectedRole === 'positive' ? 'negative' : 'positive';
|
||||
const otherCheckbox = html.match(new RegExp(`<input type="checkbox" data-image-prompt-role-selection="${otherRole}"[^>]*>`))?.[0] || '';
|
||||
|
||||
assert.match(selectedCheckbox, /checked/);
|
||||
assert.doesNotMatch(otherCheckbox, /checked/);
|
||||
|
||||
const result = await context.applyMetadataPromptTransfer();
|
||||
const workflow = context.getWorkflow();
|
||||
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(workflow.nodes[replacedNodeIndex].widgets_values[0], `imported ${selectedRole}`);
|
||||
assert.equal(
|
||||
workflow.nodes[untouchedNodeIndex].widgets_values[0],
|
||||
`current ${otherRole}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('metadata prompt transfer keeps empty current text fields selectable', () => {
|
||||
const context = createContext(createRoleWorkflow(), createRoleWorkflow('', ''));
|
||||
const rows = context.getMetadataPromptTransferRows(context.imageInspectorState);
|
||||
|
||||
assert.equal(rows[0].target.node_id, 1);
|
||||
assert.equal(rows[0].target.text, '');
|
||||
assert.equal(rows[1].target.node_id, 2);
|
||||
assert.equal(rows[1].target.text, '');
|
||||
});
|
||||
|
||||
test('metadata prompt transfer avoids selecting one unknown target for both roles', () => {
|
||||
const source = {
|
||||
nodes: [
|
||||
{ id: 1, type: 'PrimitiveStringMultiline', widgets_values: ['first'] },
|
||||
{ id: 2, type: 'PrimitiveStringMultiline', widgets_values: ['second'] },
|
||||
],
|
||||
};
|
||||
const current = {
|
||||
nodes: [
|
||||
{ id: 10, type: 'PrimitiveStringMultiline', widgets_values: ['target one'] },
|
||||
{ id: 11, type: 'PrimitiveStringMultiline', widgets_values: ['target two'] },
|
||||
],
|
||||
};
|
||||
const context = createContext(source, current);
|
||||
const rows = context.getMetadataPromptTransferRows(context.imageInspectorState);
|
||||
|
||||
assert.notEqual(rows[0].target.id, rows[1].target.id);
|
||||
});
|
||||
|
||||
test('metadata prompt transfer replaces both selected prompt fields in the current workflow', async () => {
|
||||
const context = createContext(
|
||||
createRoleWorkflow('imported positive', 'imported negative'),
|
||||
createRoleWorkflow('current positive', 'current negative'),
|
||||
);
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
|
||||
try {
|
||||
const result = await context.applyMetadataPromptTransfer();
|
||||
const workflow = context.getWorkflow();
|
||||
|
||||
assert.equal(result.updated, 2);
|
||||
assert.equal(workflow.nodes[0].widgets_values[0], 'imported positive');
|
||||
assert.equal(workflow.nodes[1].widgets_values[0], 'imported negative');
|
||||
assert.deepEqual(context.notifications, [{
|
||||
message: 'Replaced 2 prompt fields in the current workflow.',
|
||||
type: 'success',
|
||||
}]);
|
||||
} finally {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
+217
-1
@@ -4920,6 +4920,211 @@ button.mr-info-link:disabled {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mr-image-prompt-transfer {
|
||||
margin: var(--mr-spacing-12) 0 var(--mr-spacing-16);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mr-image-prompt-header,
|
||||
.mr-image-prompt-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mr-spacing-10);
|
||||
}
|
||||
|
||||
.mr-image-prompt-header {
|
||||
margin-bottom: var(--mr-spacing-10);
|
||||
}
|
||||
|
||||
.mr-image-prompt-note,
|
||||
.mr-image-prompt-selection-count,
|
||||
.mr-image-prompt-meta {
|
||||
color: var(--mr-text-muted);
|
||||
font-size: var(--mr-font-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mr-image-prompt-note {
|
||||
margin: var(--mr-spacing-8) 0 var(--mr-spacing-10);
|
||||
}
|
||||
|
||||
.mr-image-prompt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mr-spacing-8);
|
||||
}
|
||||
|
||||
.mr-image-prompt-row {
|
||||
min-width: 0;
|
||||
padding: var(--mr-spacing-10);
|
||||
border: 1px solid var(--mr-border);
|
||||
border-radius: var(--mr-radius-lg);
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.mr-image-prompt-row.is-positive {
|
||||
border-color: rgba(114, 211, 138, 0.28);
|
||||
}
|
||||
|
||||
.mr-image-prompt-row.is-negative {
|
||||
border-color: rgba(240, 180, 100, 0.28);
|
||||
}
|
||||
|
||||
.mr-image-prompt-row.is-selected {
|
||||
background: rgba(114, 211, 138, 0.055);
|
||||
}
|
||||
|
||||
.mr-image-prompt-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mr-spacing-8);
|
||||
margin-bottom: var(--mr-spacing-8);
|
||||
}
|
||||
|
||||
.mr-image-prompt-role-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mr-spacing-8);
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mr-image-prompt-role-option input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--mr-accent);
|
||||
}
|
||||
|
||||
.mr-image-prompt-role-option input:focus-visible {
|
||||
outline: 2px solid var(--mr-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mr-image-prompt-role-option input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.mr-image-prompt-role {
|
||||
color: var(--mr-text);
|
||||
font-size: var(--mr-font-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mr-image-prompt-role-chip {
|
||||
padding: var(--mr-spacing-4) var(--mr-spacing-7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
color: var(--mr-text-muted);
|
||||
font-size: var(--mr-font-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mr-image-prompt-row.is-positive .mr-image-prompt-role-chip {
|
||||
border-color: rgba(114, 211, 138, 0.30);
|
||||
color: var(--mr-accent);
|
||||
}
|
||||
|
||||
.mr-image-prompt-row.is-negative .mr-image-prompt-role-chip {
|
||||
border-color: rgba(240, 180, 100, 0.30);
|
||||
color: var(--mr-warning, var(--mr-text-muted));
|
||||
}
|
||||
|
||||
.mr-image-prompt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--mr-spacing-10);
|
||||
}
|
||||
|
||||
.mr-image-prompt-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mr-spacing-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mr-image-prompt-side.is-context-menu {
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.mr-image-prompt-side-label {
|
||||
color: var(--mr-text-muted);
|
||||
font-size: var(--mr-font-xs);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.mr-image-prompt-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 26px;
|
||||
padding: var(--mr-spacing-4) var(--mr-spacing-7);
|
||||
border: 1px solid var(--mr-border-strong, rgba(255, 255, 255, 0.24));
|
||||
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--mr-text);
|
||||
font: inherit;
|
||||
font-size: var(--mr-font-xs);
|
||||
}
|
||||
|
||||
.mr-image-prompt-select:focus {
|
||||
border-color: var(--mr-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--mr-missing-control-focus-ring, rgba(114, 211, 138, 0.08));
|
||||
}
|
||||
|
||||
.mr-image-prompt-select:disabled {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.mr-image-prompt-text {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-height: 150px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: var(--mr-spacing-8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
color: var(--mr-text);
|
||||
font-family: var(--mr-font-family-mono);
|
||||
font-size: var(--mr-font-xs);
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mr-image-prompt-text.is-current {
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.mr-image-prompt-arrow {
|
||||
color: var(--mr-accent);
|
||||
font-size: var(--mr-font-lg);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mr-image-prompt-empty {
|
||||
margin: 0;
|
||||
padding: var(--mr-spacing-8);
|
||||
border: 1px dashed var(--mr-border);
|
||||
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
|
||||
color: var(--mr-text-muted);
|
||||
font-size: var(--mr-font-xs);
|
||||
}
|
||||
|
||||
.mr-image-prompt-actions {
|
||||
margin-top: var(--mr-spacing-10);
|
||||
padding-top: var(--mr-spacing-10);
|
||||
border-top: 1px solid var(--mr-border);
|
||||
}
|
||||
|
||||
.mr-image-transfer-header,
|
||||
.mr-image-transfer-column-header,
|
||||
.mr-image-transfer-actions {
|
||||
@@ -5684,6 +5889,15 @@ button.mr-info-link:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mr-image-prompt-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mr-image-prompt-arrow {
|
||||
justify-self: start;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mr-image-transfer-category-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -5734,7 +5948,9 @@ button.mr-info-link:disabled {
|
||||
}
|
||||
|
||||
.mr-image-transfer-header,
|
||||
.mr-image-transfer-actions {
|
||||
.mr-image-transfer-actions,
|
||||
.mr-image-prompt-header,
|
||||
.mr-image-prompt-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
const PROMPT_WIDGET_NAMES = new Set([
|
||||
'text',
|
||||
'prompt',
|
||||
'positive',
|
||||
'negative',
|
||||
'positive_prompt',
|
||||
'negative_prompt',
|
||||
'prompt_positive',
|
||||
'prompt_negative',
|
||||
'text_positive',
|
||||
'text_negative',
|
||||
'text_a',
|
||||
'text_b',
|
||||
'text_g',
|
||||
'text_l',
|
||||
'text_1',
|
||||
'text_2',
|
||||
]);
|
||||
|
||||
const PROMPT_NODE_TYPE_PATTERN = /(?:clip.*text|text.*encode|primitive.*string|string.*multiline|text.*multiline|multiline.*text|conditioning.*text|textinput)/i;
|
||||
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']);
|
||||
const NON_TEXT_WIDGET_TYPES = new Set([
|
||||
'BOOLEAN',
|
||||
'COMBO',
|
||||
'DROPDOWN',
|
||||
'ENUM',
|
||||
'FLOAT',
|
||||
'INT',
|
||||
'LIST',
|
||||
'NUMBER',
|
||||
'SELECT',
|
||||
'SLIDER',
|
||||
]);
|
||||
const NON_PROMPT_WIDGET_NAME_PATTERN = /^(?:after_generate|batch(?:_size)?|cfg|control_after_generate|counter|denoise|extension|filename(?:_prefix)?|folder|format|height|list|mode|output(?:_name|_path)?|path|prefix|sampler|scheduler|seed|steps|suffix|width)$/i;
|
||||
|
||||
function normalizePromptName(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
function getInputName(input = {}) {
|
||||
return String(
|
||||
input?.name
|
||||
|| input?.widget_name
|
||||
|| input?.widgetName
|
||||
|| input?.widget?.name
|
||||
|| input?.widget?.widget
|
||||
|| ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function getRoleFromLabel(value) {
|
||||
const token = normalizePromptName(value);
|
||||
if (!token) return '';
|
||||
|
||||
const parts = new Set(token.split('_').filter(Boolean));
|
||||
if (parts.has('negative') || parts.has('neg') || parts.has('uncond') || parts.has('unconditional')) {
|
||||
return 'negative';
|
||||
}
|
||||
if (parts.has('positive') || parts.has('pos') || parts.has('cond') || parts.has('conditional')) {
|
||||
return 'positive';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isPromptInputName(value) {
|
||||
const token = normalizePromptName(value);
|
||||
return PROMPT_WIDGET_NAMES.has(token)
|
||||
|| PROMPT_INPUT_NAMES.has(token)
|
||||
|| token.includes('prompt')
|
||||
|| token.startsWith('text_')
|
||||
|| token.endsWith('_text');
|
||||
}
|
||||
|
||||
function isWidgetInput(input = {}) {
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
if (
|
||||
input.widget !== undefined
|
||||
|| input.widget_name !== undefined
|
||||
|| input.widgetName !== undefined
|
||||
|| input.isWidget === true
|
||||
) return true;
|
||||
|
||||
const type = String(input.type || '').trim().toUpperCase();
|
||||
return input.link == null && SCALAR_WIDGET_TYPES.has(type);
|
||||
}
|
||||
|
||||
function getWidgetInputs(node = {}) {
|
||||
const inputs = Array.isArray(node.inputs) ? node.inputs : [];
|
||||
return inputs.filter(isWidgetInput);
|
||||
}
|
||||
|
||||
function getWidgetNames(node = {}, valueCount = 0) {
|
||||
const names = Array.from({ length: valueCount }, () => '');
|
||||
const setName = (index, value) => {
|
||||
if (index < 0 || index >= names.length || names[index]) return;
|
||||
const name = getInputName(value);
|
||||
if (name) names[index] = name;
|
||||
};
|
||||
|
||||
const widgets = Array.isArray(node.widgets) ? node.widgets : [];
|
||||
widgets.forEach((widget, index) => setName(index, widget));
|
||||
|
||||
const widgetInputs = getWidgetInputs(node);
|
||||
widgetInputs.forEach((input, index) => setName(index, input));
|
||||
|
||||
const scalarInputs = (Array.isArray(node.inputs) ? node.inputs : [])
|
||||
.filter(input => input && input.link == null && (
|
||||
isWidgetInput(input)
|
||||
|| SCALAR_WIDGET_TYPES.has(String(input.type || '').trim().toUpperCase())
|
||||
));
|
||||
scalarInputs.forEach((input, index) => setName(index, input));
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function getWidgetInput(node = {}, widgetIndex = -1, widgetName = '') {
|
||||
const inputs = Array.isArray(node.inputs) ? node.inputs : [];
|
||||
const normalizedName = normalizePromptName(widgetName);
|
||||
if (normalizedName) {
|
||||
const named = inputs.find(input => normalizePromptName(getInputName(input)) === normalizedName);
|
||||
if (named) return named;
|
||||
}
|
||||
|
||||
const widgetInputs = getWidgetInputs(node);
|
||||
return widgetInputs[widgetIndex]
|
||||
|| inputs.filter(input => input && input.link == null)[widgetIndex]
|
||||
|| null;
|
||||
}
|
||||
|
||||
function getWidgetDescriptors(node = {}) {
|
||||
const values = Array.isArray(node.widgets_values) ? node.widgets_values : [];
|
||||
const names = getWidgetNames(node, values.length);
|
||||
const widgets = Array.isArray(node.widgets) ? node.widgets : [];
|
||||
const namedValues = node.widgets_values_named && typeof node.widgets_values_named === 'object'
|
||||
? node.widgets_values_named
|
||||
: {};
|
||||
|
||||
return Array.from({ length: values.length }, (_, index) => {
|
||||
const value = values[index];
|
||||
const name = names[index] || '';
|
||||
const namedValue = name && Object.prototype.hasOwnProperty.call(namedValues, name)
|
||||
? namedValues[name]
|
||||
: value;
|
||||
return {
|
||||
index,
|
||||
name,
|
||||
value: namedValue,
|
||||
input: getWidgetInput(node, index, name),
|
||||
widget: widgets[index] || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectWorkflowNodeEntries(workflow = {}) {
|
||||
const entries = [];
|
||||
if (!workflow || typeof workflow !== 'object') return entries;
|
||||
|
||||
const appendNodes = (nodes, {
|
||||
graphKey,
|
||||
isTopLevel,
|
||||
subgraphId = '',
|
||||
subgraphName = '',
|
||||
}) => {
|
||||
if (!Array.isArray(nodes)) return;
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object' || node.id == null) continue;
|
||||
entries.push({
|
||||
node,
|
||||
graphKey,
|
||||
isTopLevel,
|
||||
subgraphId,
|
||||
subgraphName,
|
||||
nodeKey: `${graphKey}:${String(node.id)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
appendNodes(workflow.nodes, { graphKey: 'top', isTopLevel: true });
|
||||
const subgraphs = workflow.definitions?.subgraphs;
|
||||
if (Array.isArray(subgraphs)) {
|
||||
subgraphs.forEach((subgraph, index) => {
|
||||
if (!subgraph || typeof subgraph !== 'object') return;
|
||||
const subgraphId = String(subgraph.id ?? `index-${index}`);
|
||||
appendNodes(subgraph.nodes, {
|
||||
graphKey: `sub:${subgraphId}`,
|
||||
isTopLevel: false,
|
||||
subgraphId,
|
||||
subgraphName: String(subgraph.name || subgraph.title || '').trim(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function getOutputLinks(node = {}) {
|
||||
const links = [];
|
||||
for (const output of Array.isArray(node.outputs) ? node.outputs : []) {
|
||||
const values = Array.isArray(output?.links)
|
||||
? output.links
|
||||
: output?.link != null
|
||||
? [output.link]
|
||||
: [];
|
||||
for (const link of values) {
|
||||
if (link != null && link !== '') links.push(String(link));
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function getSerializedLinkSource(link) {
|
||||
const values = Array.isArray(link)
|
||||
? link
|
||||
: [
|
||||
link?.id ?? link?.link_id,
|
||||
link?.origin_id ?? link?.originId ?? link?.source_id ?? link?.sourceId,
|
||||
];
|
||||
const linkId = values[0];
|
||||
const originNodeId = values[1];
|
||||
if (linkId == null || linkId === '' || originNodeId == null || originNodeId === '') return null;
|
||||
return { linkId: String(linkId), originNodeId: String(originNodeId) };
|
||||
}
|
||||
|
||||
function buildGraphConnections(entries, workflow = {}) {
|
||||
const sourcesByLink = new Map();
|
||||
for (const entry of entries) {
|
||||
for (const link of getOutputLinks(entry.node)) {
|
||||
sourcesByLink.set(`${entry.graphKey}:${link}`, entry.nodeKey);
|
||||
}
|
||||
}
|
||||
|
||||
const entriesByNodeKey = new Map(entries.map(entry => [entry.nodeKey, entry]));
|
||||
const addSerializedLinks = (links, graphKey) => {
|
||||
for (const link of Array.isArray(links) ? links : []) {
|
||||
const serialized = getSerializedLinkSource(link);
|
||||
if (!serialized) continue;
|
||||
const sourceNodeKey = `${graphKey}:${serialized.originNodeId}`;
|
||||
if (!entriesByNodeKey.has(sourceNodeKey)) continue;
|
||||
const linkKey = `${graphKey}:${serialized.linkId}`;
|
||||
if (!sourcesByLink.has(linkKey)) sourcesByLink.set(linkKey, sourceNodeKey);
|
||||
}
|
||||
};
|
||||
|
||||
addSerializedLinks(workflow.links, 'top');
|
||||
const subgraphs = Array.isArray(workflow.definitions?.subgraphs)
|
||||
? workflow.definitions.subgraphs
|
||||
: [];
|
||||
for (const [index, subgraph] of subgraphs.entries()) {
|
||||
if (!subgraph || typeof subgraph !== 'object') continue;
|
||||
const subgraphId = String(subgraph.id ?? `index-${index}`);
|
||||
addSerializedLinks(subgraph.links, `sub:${subgraphId}`);
|
||||
}
|
||||
|
||||
return { sourcesByLink };
|
||||
}
|
||||
|
||||
function resolveRole(roleCandidates) {
|
||||
const candidates = Array.from(new Set(roleCandidates)).filter(Boolean);
|
||||
return {
|
||||
role: candidates.length === 1 ? candidates[0] : 'unknown',
|
||||
roleCandidates: candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeRoleCandidates(entry) {
|
||||
const node = entry.node;
|
||||
const candidates = [];
|
||||
for (const label of [
|
||||
node.title,
|
||||
node.name,
|
||||
node.properties?.['Node name for S&R'],
|
||||
node.properties?.node_name,
|
||||
node.properties?.nodeName,
|
||||
node.type,
|
||||
]) {
|
||||
const role = getRoleFromLabel(label);
|
||||
if (role) candidates.push(role);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isDedicatedTextNode(node = {}) {
|
||||
const nodeType = String(node.type || node.comfyClass || '').trim();
|
||||
return PROMPT_NODE_TYPE_PATTERN.test(nodeType);
|
||||
}
|
||||
|
||||
function isPromptPathInput(entry, input = {}) {
|
||||
const name = normalizePromptName(getInputName(input));
|
||||
const type = normalizeWidgetType(input.type);
|
||||
if (!name && !type) return false;
|
||||
if (NON_PROMPT_WIDGET_NAME_PATTERN.test(name)) return false;
|
||||
if (
|
||||
type === 'INT'
|
||||
|| type === 'FLOAT'
|
||||
|| type === 'BOOLEAN'
|
||||
|| type === 'COMBO'
|
||||
|| type === 'DROPDOWN'
|
||||
|| type === 'ENUM'
|
||||
|| type === 'LIST'
|
||||
|| type === 'NUMBER'
|
||||
|| type === 'SELECT'
|
||||
|| type === 'SLIDER'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
getRoleFromLabel(name)
|
||||
|| isPromptInputName(name)
|
||||
|| name.includes('conditioning')
|
||||
|| type === 'STRING'
|
||||
|| type === 'TEXT'
|
||||
|| type.includes('STRING')
|
||||
|| type.includes('TEXT')
|
||||
|| type.includes('CONDITIONING')
|
||||
|| (isDedicatedTextNode(entry.node) && Boolean(name))
|
||||
);
|
||||
}
|
||||
|
||||
function addRoleCandidate(roleCandidatesByNodeKey, nodeKey, role) {
|
||||
if (!role) return;
|
||||
const candidates = roleCandidatesByNodeKey.get(nodeKey) || [];
|
||||
if (!candidates.includes(role)) candidates.push(role);
|
||||
roleCandidatesByNodeKey.set(nodeKey, candidates);
|
||||
}
|
||||
|
||||
function buildRoleCandidatesFromGraph(entries, sourcesByLink) {
|
||||
const roleCandidatesByNodeKey = new Map();
|
||||
for (const entry of entries) {
|
||||
roleCandidatesByNodeKey.set(entry.nodeKey, getNodeRoleCandidates(entry));
|
||||
}
|
||||
|
||||
const entriesByNodeKey = new Map(entries.map(entry => [entry.nodeKey, entry]));
|
||||
const visitUpstream = (startEntry, role) => {
|
||||
const queue = [startEntry];
|
||||
const visited = new Set();
|
||||
while (queue.length) {
|
||||
const entry = queue.shift();
|
||||
if (!entry || visited.has(entry.nodeKey)) continue;
|
||||
visited.add(entry.nodeKey);
|
||||
addRoleCandidate(roleCandidatesByNodeKey, entry.nodeKey, role);
|
||||
|
||||
for (const input of Array.isArray(entry.node.inputs) ? entry.node.inputs : []) {
|
||||
if (input?.link == null || input.link === '' || !isPromptPathInput(entry, input)) continue;
|
||||
const sourceNodeKey = sourcesByLink.get(`${entry.graphKey}:${String(input.link)}`);
|
||||
const sourceEntry = entriesByNodeKey.get(sourceNodeKey);
|
||||
if (sourceEntry && !visited.has(sourceEntry.nodeKey)) queue.push(sourceEntry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const input of Array.isArray(entry.node.inputs) ? entry.node.inputs : []) {
|
||||
const role = getRoleFromLabel(getInputName(input));
|
||||
if (!role || input?.link == null || input.link === '') continue;
|
||||
const sourceNodeKey = sourcesByLink.get(`${entry.graphKey}:${String(input.link)}`);
|
||||
const sourceEntry = entriesByNodeKey.get(sourceNodeKey);
|
||||
if (sourceEntry) visitUpstream(sourceEntry, role);
|
||||
}
|
||||
}
|
||||
|
||||
return roleCandidatesByNodeKey;
|
||||
}
|
||||
|
||||
function normalizeWidgetType(value) {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value;
|
||||
return String(rawValue ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '_');
|
||||
}
|
||||
|
||||
function getDescriptorWidgetType(descriptor = {}) {
|
||||
return normalizeWidgetType(
|
||||
descriptor.input?.type
|
||||
|| descriptor.widget?.type
|
||||
|| descriptor.input?.widget?.type
|
||||
|| descriptor.input?.widget?.widget_type
|
||||
|| descriptor.input?.widget?.widgetType
|
||||
);
|
||||
}
|
||||
|
||||
function hasWidgetChoices(descriptor = {}) {
|
||||
return [descriptor.input, descriptor.widget, descriptor.input?.widget]
|
||||
.some(source => Array.isArray(source?.options) && source.options.length > 0);
|
||||
}
|
||||
|
||||
function isNonTextWidgetType(type) {
|
||||
return NON_TEXT_WIDGET_TYPES.has(type)
|
||||
|| type.includes('COMBO')
|
||||
|| type.includes('DROPDOWN')
|
||||
|| type.includes('ENUM')
|
||||
|| type.includes('LIST')
|
||||
|| type.includes('SELECT')
|
||||
|| type.includes('SLIDER');
|
||||
}
|
||||
|
||||
function isTextLikeDescriptor(descriptor = {}) {
|
||||
const type = getDescriptorWidgetType(descriptor);
|
||||
return TEXT_WIDGET_TYPES.has(type)
|
||||
|| type.includes('STRING')
|
||||
|| type.includes('TEXT');
|
||||
}
|
||||
|
||||
function isPromptTextDescriptor(descriptor = {}, { node = {}, hasGraphPromptRole = false } = {}) {
|
||||
const name = normalizePromptName(descriptor.name);
|
||||
const type = getDescriptorWidgetType(descriptor);
|
||||
const hasExplicitPromptName = Boolean(
|
||||
getRoleFromLabel(descriptor.name)
|
||||
|| PROMPT_WIDGET_NAMES.has(name)
|
||||
|| PROMPT_INPUT_NAMES.has(name)
|
||||
|| name.includes('prompt')
|
||||
|| name.startsWith('text_')
|
||||
|| name.endsWith('_text')
|
||||
);
|
||||
if (isNonTextWidgetType(type) || hasWidgetChoices(descriptor)) return false;
|
||||
if (NON_PROMPT_WIDGET_NAME_PATTERN.test(name)) return false;
|
||||
|
||||
return hasExplicitPromptName
|
||||
|| (isTextLikeDescriptor(descriptor) && (
|
||||
isDedicatedTextNode(node)
|
||||
|| hasGraphPromptRole
|
||||
));
|
||||
}
|
||||
|
||||
function promptIdentity(entry, descriptor) {
|
||||
return `${entry.graphKey}:${String(entry.node.id)}:${descriptor.index}:${normalizePromptName(descriptor.name)}`;
|
||||
}
|
||||
|
||||
function findWorkflowNodeForPrompt(workflow = {}, prompt = {}) {
|
||||
const nodeId = String(prompt.node_id ?? '');
|
||||
if (!nodeId) return null;
|
||||
|
||||
if (prompt.is_top_level !== false) {
|
||||
return (workflow.nodes || []).find(node => String(node?.id) === nodeId) || null;
|
||||
}
|
||||
|
||||
const subgraphId = String(prompt.subgraph_id || '');
|
||||
const subgraphs = Array.isArray(workflow.definitions?.subgraphs)
|
||||
? workflow.definitions.subgraphs
|
||||
: [];
|
||||
return subgraphs
|
||||
.find(subgraph => String(subgraph?.id ?? '') === subgraphId)
|
||||
?.nodes?.find(node => String(node?.id) === nodeId) || null;
|
||||
}
|
||||
|
||||
export function extractWorkflowPrompts(workflow = {}) {
|
||||
const entries = collectWorkflowNodeEntries(workflow);
|
||||
if (!entries.length) return [];
|
||||
|
||||
const { sourcesByLink } = buildGraphConnections(entries, workflow);
|
||||
const roleCandidatesByNodeKey = buildRoleCandidatesFromGraph(entries, sourcesByLink);
|
||||
|
||||
const prompts = [];
|
||||
for (const entry of entries) {
|
||||
const node = entry.node;
|
||||
const descriptors = getWidgetDescriptors(node);
|
||||
const nodeRoleCandidates = roleCandidatesByNodeKey.get(entry.nodeKey) || [];
|
||||
const stringDescriptorCount = descriptors.filter(descriptor => (
|
||||
descriptor && typeof descriptor.value === 'string'
|
||||
)).length;
|
||||
|
||||
for (const descriptor of descriptors) {
|
||||
if (!descriptor || typeof descriptor.value !== 'string') continue;
|
||||
const text = descriptor.value;
|
||||
|
||||
const isUnnamedTextFallback = !descriptor.name
|
||||
&& !descriptor.input
|
||||
&& stringDescriptorCount === 1
|
||||
&& (isDedicatedTextNode(node) || nodeRoleCandidates.length === 1);
|
||||
const candidate = Boolean(
|
||||
isPromptTextDescriptor(descriptor, {
|
||||
node,
|
||||
hasGraphPromptRole: nodeRoleCandidates.length > 0,
|
||||
})
|
||||
|| isUnnamedTextFallback
|
||||
);
|
||||
if (!candidate) continue;
|
||||
|
||||
const combinedRole = resolveRole([
|
||||
...nodeRoleCandidates,
|
||||
getRoleFromLabel(descriptor.name),
|
||||
]);
|
||||
const widgetInput = descriptor.input || getWidgetInput(node, descriptor.index, descriptor.name);
|
||||
prompts.push({
|
||||
id: promptIdentity(entry, descriptor),
|
||||
node_id: node.id,
|
||||
node_type: String(node.type || node.comfyClass || '').trim(),
|
||||
node_title: String(node.title || '').trim(),
|
||||
widget_index: descriptor.index,
|
||||
widget_name: descriptor.name,
|
||||
text,
|
||||
role: combinedRole.role,
|
||||
role_candidates: combinedRole.roleCandidates,
|
||||
is_top_level: entry.isTopLevel,
|
||||
subgraph_id: entry.subgraphId,
|
||||
subgraph_name: entry.subgraphName,
|
||||
linked: Boolean(
|
||||
widgetInput
|
||||
&& widgetInput.link != null
|
||||
&& widgetInput.link !== ''
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return prompts;
|
||||
}
|
||||
|
||||
export function updateWorkflowPromptValue(workflow, prompt = {}, value = '') {
|
||||
if (!workflow || !prompt || prompt.linked) return false;
|
||||
|
||||
const node = findWorkflowNodeForPrompt(workflow, prompt);
|
||||
const widgetIndex = Number(prompt.widget_index);
|
||||
if (!node || !Array.isArray(node.widgets_values) || !Number.isInteger(widgetIndex) || widgetIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const widgetInput = getWidgetInput(node, widgetIndex, prompt.widget_name);
|
||||
if (widgetInput && widgetInput.link != null && widgetInput.link !== '') return false;
|
||||
|
||||
const nextValue = String(value ?? '');
|
||||
node.widgets_values[widgetIndex] = nextValue;
|
||||
if (
|
||||
prompt.widget_name
|
||||
&& node.widgets_values_named
|
||||
&& typeof node.widgets_values_named === 'object'
|
||||
&& Object.prototype.hasOwnProperty.call(node.widgets_values_named, prompt.widget_name)
|
||||
) {
|
||||
node.widgets_values_named[prompt.widget_name] = nextValue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getPromptRoleLabel(role) {
|
||||
if (role === 'positive') return 'Positive prompt';
|
||||
if (role === 'negative') return 'Negative prompt';
|
||||
return 'Unclassified prompt';
|
||||
}
|
||||
@@ -4,11 +4,18 @@ import {
|
||||
toResolverContextModel,
|
||||
} from "../node_context_menu.js";
|
||||
import { normalizeCategoryValue } from "../utils/category_utils.js";
|
||||
import {
|
||||
extractWorkflowPrompts,
|
||||
getPromptRoleLabel,
|
||||
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_PROMPT_ROLES = ['positive', 'negative'];
|
||||
const MAX_PROMPT_PREVIEW_LENGTH = 12_000;
|
||||
|
||||
const MERGE_LORA_NODE_TYPES = new Set([
|
||||
'LoraLoaderV2',
|
||||
@@ -73,6 +80,7 @@ function getImageInspectorState(dialog) {
|
||||
loading: '',
|
||||
requestToken: null,
|
||||
transfer: null,
|
||||
promptTransfer: null,
|
||||
lastTransfer: null,
|
||||
};
|
||||
}
|
||||
@@ -263,6 +271,7 @@ export const imageMetadataMethods = {
|
||||
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 || '');
|
||||
@@ -290,6 +299,42 @@ export const imageMetadataMethods = {
|
||||
}
|
||||
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);
|
||||
@@ -425,6 +470,11 @@ export const imageMetadataMethods = {
|
||||
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;
|
||||
@@ -503,6 +553,7 @@ export const imageMetadataMethods = {
|
||||
: 'Reading embedded metadata...';
|
||||
result.innerHTML = `
|
||||
<div class="mr-image-inspector-loading"><span class="mr-spinner"></span>${this.escapeHtml(message)}</div>
|
||||
${this.renderMetadataPromptTransferPanel(state)}
|
||||
${this.renderMetadataTransferPanel(state)}
|
||||
`;
|
||||
return;
|
||||
@@ -510,12 +561,13 @@ export const imageMetadataMethods = {
|
||||
if (state.error) {
|
||||
result.innerHTML = `
|
||||
<p class="mr-error-text">${this.escapeHtml(state.error)}</p>
|
||||
${this.renderMetadataPromptTransferPanel(state)}
|
||||
${this.renderMetadataTransferPanel(state)}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
if (!state.metadata) {
|
||||
result.innerHTML = this.renderMetadataTransferPanel(state);
|
||||
result.innerHTML = `${this.renderMetadataPromptTransferPanel(state)}${this.renderMetadataTransferPanel(state)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -577,6 +629,7 @@ export const imageMetadataMethods = {
|
||||
</div>
|
||||
<table class="mr-info-table mr-image-inspector-info-table"><tbody>${rowsHtml}${parametersHtml}</tbody></table>
|
||||
</section>
|
||||
${this.renderMetadataPromptTransferPanel(state)}
|
||||
${this.renderMetadataTransferPanel(state)}
|
||||
<section class="mr-image-inspector-models">
|
||||
<div id="${this.escapeHtml(modelContainerId)}"></div>
|
||||
@@ -606,6 +659,348 @@ export const imageMetadataMethods = {
|
||||
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…`;
|
||||
},
|
||||
|
||||
getMetadataPromptTransferData(state = null) {
|
||||
const imageState = state || getImageInspectorState(this);
|
||||
const promptTransfer = this.getMetadataPromptTransferState(imageState);
|
||||
const sourcePrompts = extractWorkflowPrompts(imageState.metadata?.workflow || {})
|
||||
.filter(prompt => !prompt.linked && String(prompt.text || '').trim());
|
||||
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 = sourcePrompts.filter(prompt => (
|
||||
prompt.role === role || prompt.role === 'unknown'
|
||||
));
|
||||
const targetOptions = targetPrompts.filter(prompt => (
|
||||
prompt.role === role || prompt.role === 'unknown'
|
||||
));
|
||||
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);
|
||||
const sourceSummary = data.sourcePrompts.length
|
||||
? `${data.sourcePrompts.length} editable prompt${data.sourcePrompts.length === 1 ? '' : 's'} found in the imported workflow.`
|
||||
: imageState.loading
|
||||
? 'Reading prompt text from the imported workflow...'
|
||||
: imageState.metadata?.workflow
|
||||
? 'No editable prompt text nodes were found in the imported workflow.'
|
||||
: 'Import an image or workflow JSON with prompt nodes to enable prompt transfer.';
|
||||
const targetSummary = data.targetPrompts.length
|
||||
? `${data.targetPrompts.length} editable prompt field${data.targetPrompts.length === 1 ? '' : 's'} in the current workflow.`
|
||||
: 'No editable prompt text fields were 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 => `
|
||||
<option value="${this.escapeHtml(prompt.id)}"${row.source?.id === prompt.id ? ' selected' : ''}>
|
||||
${this.escapeHtml(this.getMetadataPromptLabel(prompt))}
|
||||
</option>
|
||||
`).join('');
|
||||
const targetOptions = row.targetOptions.map(prompt => `
|
||||
<option value="${this.escapeHtml(prompt.id)}"${row.target?.id === prompt.id ? ' selected' : ''}>
|
||||
${this.escapeHtml(this.getMetadataPromptLabel(prompt))}
|
||||
</option>
|
||||
`).join('');
|
||||
const sourceRoleHint = row.source?.role === row.role
|
||||
? 'Role detected from workflow connections.'
|
||||
: 'Role not explicit; choose the source text for this slot.';
|
||||
const targetRoleHint = row.target?.role === row.role
|
||||
? 'Positive/negative role detected from workflow connections.'
|
||||
: 'Role not explicit; choose the target text field for this slot.';
|
||||
const targetContextMenuAttrs = row.target
|
||||
? this.getMetadataPromptNodeContextMenuAttrs?.(row.target) || ''
|
||||
: '';
|
||||
const sourceHtml = row.source
|
||||
? `
|
||||
<select class="mr-image-prompt-select" data-image-prompt-source="${row.role}" aria-label="Select imported ${row.role} prompt"${row.sourceOptions.length < 2 ? ' disabled' : ''}>
|
||||
${sourceOptions}
|
||||
</select>
|
||||
<pre class="mr-image-prompt-text" title="${this.escapeHtml(String(row.source.text || ''))}">${this.escapeHtml(this.getMetadataPromptPreviewText(row.source) || '(empty)')}</pre>
|
||||
<span class="mr-image-prompt-meta">${this.escapeHtml(sourceRoleHint)}</span>
|
||||
`
|
||||
: '<p class="mr-image-prompt-empty">No imported prompt available.</p>';
|
||||
const targetHtml = row.target
|
||||
? `
|
||||
<select class="mr-image-prompt-select" data-image-prompt-target="${row.role}" aria-label="Select current ${row.role} text field"${row.targetOptions.length < 2 ? ' disabled' : ''}>
|
||||
${targetOptions}
|
||||
</select>
|
||||
<pre class="mr-image-prompt-text is-current" title="${this.escapeHtml(String(row.target.text || ''))}">${this.escapeHtml(this.getMetadataPromptPreviewText(row.target) || '(empty)')}</pre>
|
||||
<span class="mr-image-prompt-meta">${this.escapeHtml(targetRoleHint)}</span>
|
||||
`
|
||||
: '<p class="mr-image-prompt-empty">No current text field available.</p>';
|
||||
return `
|
||||
<article class="mr-image-prompt-row is-${row.role}${row.selected ? ' is-selected' : ''}">
|
||||
<div class="mr-image-prompt-row-header">
|
||||
<label class="mr-image-prompt-role-option">
|
||||
<input type="checkbox" data-image-prompt-role-selection="${row.role}" aria-label="Replace ${this.escapeHtml(row.label)}"${row.selected ? ' checked' : ''}>
|
||||
<span class="mr-image-prompt-role">${this.escapeHtml(row.label)}</span>
|
||||
</label>
|
||||
<span class="mr-image-prompt-role-chip">${row.selected ? (row.applicable ? 'Selected' : 'Needs selection') : 'Not selected'}</span>
|
||||
</div>
|
||||
<div class="mr-image-prompt-grid">
|
||||
<div class="mr-image-prompt-side${targetContextMenuAttrs ? ' is-context-menu' : ''}"${targetContextMenuAttrs}>
|
||||
<span class="mr-image-prompt-side-label">Current text field</span>
|
||||
${targetHtml}
|
||||
</div>
|
||||
<span class="mr-image-prompt-arrow" aria-hidden="true">→</span>
|
||||
<div class="mr-image-prompt-side">
|
||||
<span class="mr-image-prompt-side-label">Imported</span>
|
||||
${sourceHtml}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
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 statusHtml = promptTransfer.error
|
||||
? `<p class="mr-error-text">${this.escapeHtml(promptTransfer.error)}</p>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<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>
|
||||
</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>
|
||||
<div class="mr-image-prompt-actions">
|
||||
<span class="mr-image-prompt-selection-count">${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.`
|
||||
: 'Select an imported prompt and a current text field.'
|
||||
)}</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
|
||||
? '<span class="mr-image-transfer-action-spinner" aria-hidden="true"></span><span>Replacing...</span>'
|
||||
: 'Replace prompts'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
},
|
||||
|
||||
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
|
||||
@@ -698,7 +1093,7 @@ export const imageMetadataMethods = {
|
||||
},
|
||||
|
||||
getMetadataTransferModelInteractionAttrs(model = {}, contextScope = 'loaded_model', label = '') {
|
||||
if (!isExistingResolvedModel(model)) return '';
|
||||
if (!model || !isExistingResolvedModel(model)) return '';
|
||||
|
||||
let contextModel;
|
||||
if (contextScope === 'local_model') {
|
||||
@@ -1336,7 +1731,7 @@ export const imageMetadataMethods = {
|
||||
`;
|
||||
}).join('');
|
||||
};
|
||||
const renderNode = (node, isAvailable) => {
|
||||
const renderNode = node => {
|
||||
const selected = selectedNodeKeys.has(node.nodeKey);
|
||||
const scopeLabel = node.isTopLevel
|
||||
? ''
|
||||
@@ -1345,7 +1740,6 @@ export const imageMetadataMethods = {
|
||||
const nodeType = node.nodeType && node.nodeType !== nodeName
|
||||
? node.nodeType
|
||||
: 'Workflow node';
|
||||
const unavailable = sourceEntries.length > 0 && !isAvailable;
|
||||
const contextMenuAttrs = this.getContextMenuAttrs?.({
|
||||
context_scope: 'workflow_node',
|
||||
node_id: node.nodeId,
|
||||
@@ -1362,12 +1756,11 @@ export const imageMetadataMethods = {
|
||||
: '<p class="mr-image-transfer-empty">No imported LoRAs match the selected activity scope.</p>'
|
||||
: renderUnselectedPreview(node);
|
||||
return `
|
||||
<div class="mr-image-transfer-node-row${selected ? ' is-selected' : ''}${unavailable ? ' is-unavailable' : ''}" ${contextMenuAttrs}>
|
||||
<div class="mr-image-transfer-node-row${selected ? ' is-selected' : ''}" ${contextMenuAttrs}>
|
||||
<button type="button" class="mr-image-transfer-node-select"
|
||||
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
|
||||
aria-pressed="${selected ? 'true' : 'false'}"
|
||||
aria-disabled="${unavailable ? 'true' : 'false'}"
|
||||
${unavailable ? 'disabled title="No matching model category in the metadata workflow"' : ''}>
|
||||
aria-disabled="false">
|
||||
<span class="mr-image-transfer-node-check" aria-hidden="true">${selected ? '✓' : ''}</span>
|
||||
<span class="mr-image-transfer-node-details">
|
||||
<span class="mr-image-transfer-node-name">${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}</span>
|
||||
@@ -1400,7 +1793,7 @@ export const imageMetadataMethods = {
|
||||
${isUnavailable ? '<span class="mr-image-transfer-unavailable-pill">No source model</span>' : ''}
|
||||
</span>
|
||||
</div>
|
||||
${collapsed ? '' : `<div class="mr-image-transfer-category-nodes">${nodes.map(node => renderNode(node, isAvailable)).join('')}</div>`}
|
||||
${collapsed ? '' : `<div class="mr-image-transfer-category-nodes">${nodes.map(renderNode).join('')}</div>`}
|
||||
</section>
|
||||
`;
|
||||
}).join('')
|
||||
@@ -1557,6 +1950,7 @@ export const imageMetadataMethods = {
|
||||
: [];
|
||||
}
|
||||
this.renderMetadataTransferPanelInPlace({ fallback: !keepCurrentTransferContent });
|
||||
this.renderMetadataPromptTransferPanelInPlace({ fallback: !keepCurrentTransferContent });
|
||||
|
||||
if (!workflow) {
|
||||
state.transfer.loading = false;
|
||||
@@ -1594,23 +1988,17 @@ export const imageMetadataMethods = {
|
||||
state.transfer.refreshing = false;
|
||||
state.transfer.targetModels = targetModels;
|
||||
state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels);
|
||||
const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels);
|
||||
const sourceCategories = new Set(
|
||||
sourceEntries
|
||||
.map(({ model }) => normalizeTransferCategory(model.category))
|
||||
);
|
||||
const selectableNodeKeys = new Set(
|
||||
const targetNodeKeys = new Set(
|
||||
state.transfer.targetGroups.flatMap(group => (
|
||||
sourceEntries.length === 0 || sourceCategories.has(group.category)
|
||||
? (group.nodes || []).map(node => node.nodeKey)
|
||||
: []
|
||||
(group.nodes || []).map(node => node.nodeKey)
|
||||
))
|
||||
);
|
||||
state.transfer.selectedNodeKeys = new Set(
|
||||
Array.from(state.transfer.selectedNodeKeys || [])
|
||||
.filter(nodeKey => selectableNodeKeys.has(nodeKey))
|
||||
.filter(nodeKey => targetNodeKeys.has(nodeKey))
|
||||
);
|
||||
this.renderMetadataTransferPanelInPlace();
|
||||
this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
|
||||
return targetModels;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -1621,6 +2009,7 @@ export const imageMetadataMethods = {
|
||||
state.transfer.refreshing = false;
|
||||
state.transfer.error = error?.message || 'Could not scan the current workflow.';
|
||||
this.renderMetadataTransferPanelInPlace();
|
||||
this.renderMetadataPromptTransferPanelInPlace({ fallback: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -1653,17 +2042,7 @@ export const imageMetadataMethods = {
|
||||
.filter(node => node.nodeKey === String(nodeKey));
|
||||
if (!nodeGroups.length) return;
|
||||
|
||||
const sourceCategories = new Set(
|
||||
this.getMetadataTransferSourceModels(state.loadedModels)
|
||||
.map(({ model }) => normalizeTransferCategory(model.category))
|
||||
);
|
||||
const selected = new Set(state.transfer.selectedNodeKeys || []);
|
||||
if (sourceCategories.size && !nodeGroups.some(node => {
|
||||
const categoryGroup = (state.transfer.targetGroups || [])
|
||||
.find(group => group.nodes.includes(node));
|
||||
return categoryGroup && sourceCategories.has(categoryGroup.category);
|
||||
}) && !selected.has(String(nodeKey))) return;
|
||||
|
||||
if (selected.has(String(nodeKey))) {
|
||||
selected.delete(String(nodeKey));
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user