diff --git a/tests/test_image_metadata_transfer.mjs b/tests/test_image_metadata_transfer.mjs index dc376f4..d8bdbb4 100644 --- a/tests/test_image_metadata_transfer.mjs +++ b/tests/test_image_metadata_transfer.mjs @@ -214,6 +214,41 @@ test('metadata transfer shows a spinner while applying', () => { assert.match(html, /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(/]*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(/]*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([ diff --git a/tests/test_prompt_transfer.mjs b/tests/test_prompt_transfer.mjs new file mode 100644 index 0000000..5059b67 --- /dev/null +++ b/tests/test_prompt_transfer.mjs @@ -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, '''), + 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(`]*>`))?.[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(`]*>`))?.[0] || ''; + const otherRole = selectedRole === 'positive' ? 'negative' : 'positive'; + const otherCheckbox = html.match(new RegExp(`]*>`))?.[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; + } +}); diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 148796a..73eb8bf 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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; } diff --git a/web/resolver/utils/prompt_utils.js b/web/resolver/utils/prompt_utils.js new file mode 100644 index 0000000..6364f7c --- /dev/null +++ b/web/resolver/utils/prompt_utils.js @@ -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'; +} diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 65a07d4..5e1d3ec 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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 = `
${this.escapeHtml(message)}
+ ${this.renderMetadataPromptTransferPanel(state)} ${this.renderMetadataTransferPanel(state)} `; return; @@ -510,12 +561,13 @@ export const imageMetadataMethods = { if (state.error) { result.innerHTML = `

${this.escapeHtml(state.error)}

+ ${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 = { ${rowsHtml}${parametersHtml}
+ ${this.renderMetadataPromptTransferPanel(state)} ${this.renderMetadataTransferPanel(state)}
@@ -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 => ` + + `).join(''); + const targetOptions = row.targetOptions.map(prompt => ` + + `).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 + ? ` + +
${this.escapeHtml(this.getMetadataPromptPreviewText(row.source) || '(empty)')}
+ ${this.escapeHtml(sourceRoleHint)} + ` + : '

No imported prompt available.

'; + const targetHtml = row.target + ? ` + +
${this.escapeHtml(this.getMetadataPromptPreviewText(row.target) || '(empty)')}
+ ${this.escapeHtml(targetRoleHint)} + ` + : '

No current text field available.

'; + return ` +
+
+ + ${row.selected ? (row.applicable ? 'Selected' : 'Needs selection') : 'Not selected'} +
+
+
+ Current text field + ${targetHtml} +
+ +
+ Imported + ${sourceHtml} +
+
+
+ `; + }).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 + ? `

${this.escapeHtml(promptTransfer.error)}

` + : ''; + + return ` +
+
+
+

Transfer prompts to current workflow

+

${this.escapeHtml(sourceSummary)} ${this.escapeHtml(targetSummary)}

+
+
+ ${statusHtml} +

${this.escapeHtml(unclassifiedNote)} Select which prompt roles to replace, then choose the source and target text fields.

+
+ ${roleRows || '

No positive or negative prompt slots are available.

'} +
+
+ ${this.escapeHtml( + data.hasDuplicateTargetSelection + ? 'Choose a different current text field for each selected role.' + : !data.selectedRoleCount + ? 'Select positive, negative, or both prompt roles.' + : hasApplicablePair + ? `${data.applicableSelectedRoleCount} prompt role${data.applicableSelectedRoleCount === 1 ? '' : 's'} selected for replacement.` + : 'Select an imported prompt and a current text field.' + )} + +
+
+ `; + }, + + 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 = { : '

No imported LoRAs match the selected activity scope.

' : renderUnselectedPreview(node); return ` -
+
- ${collapsed ? '' : `
${nodes.map(node => renderNode(node, isAvailable)).join('')}
`} + ${collapsed ? '' : `
${nodes.map(renderNode).join('')}
`}
`; }).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 {