diff --git a/README.md b/README.md index 8387d8d..1e1db44 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,12 @@ when the fields are collapsed, so LoRA trigger outputs can connect directly. Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts support ComfyUI Classic and Nodes 2.0. +**Prompt Manager (Split)** keeps the same editor and library visible together. +Wide nodes place the library beside the editor; narrow nodes stack the panels. +It shares Focused's category/tag autocomplete, search filters, preview-before-use, +workflow persistence, and connectable prepend/append inputs. Encoding and storage +match Classic and Focused. + ### 🔬 ComfyUI Workflow Analysis - **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data diff --git a/__init__.py b/__init__.py index f4e3076..f05e973 100644 --- a/__init__.py +++ b/__init__.py @@ -6,6 +6,7 @@ gallery system. This module provides prompt node types: - PromptManager: CLIP encoding node that outputs CONDITIONING - PromptManagerFocused: Editor-first layout using the same encoding contract +- PromptManagerSplit: Side-by-side editor and library using the same contract - PromptManagerText: Text-only node that outputs STRING with prepend/append functionality @@ -39,13 +40,18 @@ def get_version(): return "unknown" -from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402 +from .prompt_manager import ( # noqa: E402 + PromptManager, + PromptManagerFocused, + PromptManagerSplit, +) from .prompt_manager_text import PromptManagerText # noqa: E402 from .prompt_search_list import PromptSearchList # noqa: E402 NODE_CLASS_MAPPINGS = { "PromptManager": PromptManager, "PromptManagerFocused": PromptManagerFocused, + "PromptManagerSplit": PromptManagerSplit, "PromptManagerText": PromptManagerText, "PromptSearchList": PromptSearchList, } @@ -53,6 +59,7 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { "PromptManager": "Prompt Manager (Classic)", "PromptManagerFocused": "Prompt Manager (Focused)", + "PromptManagerSplit": "Prompt Manager (Split)", "PromptManagerText": "Prompt Manager Text", "PromptSearchList": "Prompt Search List", } diff --git a/docs/screenshots/split-toolkit/classic-start-split-dark.png b/docs/screenshots/split-toolkit/classic-start-split-dark.png new file mode 100644 index 0000000..f590c8a Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-dark.png differ diff --git a/docs/screenshots/split-toolkit/classic-start-split-default-selected.png b/docs/screenshots/split-toolkit/classic-start-split-default-selected.png new file mode 100644 index 0000000..0fa89e5 Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-default-selected.png differ diff --git a/docs/screenshots/split-toolkit/classic-start-split-light.png b/docs/screenshots/split-toolkit/classic-start-split-light.png new file mode 100644 index 0000000..a362387 Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-light.png differ diff --git a/docs/screenshots/split-toolkit/classic-start-split-narrow.png b/docs/screenshots/split-toolkit/classic-start-split-narrow.png new file mode 100644 index 0000000..e1ab08e Binary files /dev/null and b/docs/screenshots/split-toolkit/classic-start-split-narrow.png differ diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-dark.png b/docs/screenshots/split-toolkit/nodes2-start-split-dark.png new file mode 100644 index 0000000..2081f18 Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-dark.png differ diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-default-selected.png b/docs/screenshots/split-toolkit/nodes2-start-split-default-selected.png new file mode 100644 index 0000000..b678959 Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-default-selected.png differ diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-light.png b/docs/screenshots/split-toolkit/nodes2-start-split-light.png new file mode 100644 index 0000000..183d5c3 Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-light.png differ diff --git a/docs/screenshots/split-toolkit/nodes2-start-split-narrow.png b/docs/screenshots/split-toolkit/nodes2-start-split-narrow.png new file mode 100644 index 0000000..3e1fb09 Binary files /dev/null and b/docs/screenshots/split-toolkit/nodes2-start-split-narrow.png differ diff --git a/prompt_manager.py b/prompt_manager.py index a0d9fce..b070e02 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -296,3 +296,7 @@ class PromptManagerFocused(PromptManager): {**options, "default": "", "forceInput": True}, ) return inputs + + +class PromptManagerSplit(PromptManagerFocused): + """The persistent library layout shares Focused encoding and storage.""" diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index cf78a3c..acc6fe0 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -2,6 +2,17 @@ import {readFileSync} from 'node:fs'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +for (const variant of ['Focused', 'Split']) { +test.describe(variant, () => { +const isSplit = variant === 'Split'; +const uiProperty = isSplit ? 'splitUI' : 'focusedUI'; +async function openLibrary(page) { + if (isSplit) { + await expect(page.locator('.pm-editor-library')).toBeVisible(); + await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0); + } else await page.getByRole('button',{name:'Browse library',exact:true}).click(); +} + test.beforeEach(async({page,browserName})=>{ await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true})); await startCoverage(page,browserName); @@ -10,26 +21,26 @@ test.beforeEach(async({page,browserName})=>{ await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'})); await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')})); await page.goto('/prompt_manager/test'); - await page.evaluate(async()=>{ + await page.evaluate(async variant=>{ document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222'); window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}}; await import('/extensions/promptmanager/prompt_manager.js'); class Node { constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} - addDOMWidget(name,type,element){document.body.append(element);return {element};} + addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};} setSize(size){this.size=size;}setDirtyCanvas(){} serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};} configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};} } - await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp); + await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp); window.node=new Node();node.onNodeCreated(); - }); + },variant); await expect(page.locator('.pm-focused')).toBeVisible(); }); test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);}); -test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => { +test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => { const requests=[]; await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}})); await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}})); @@ -38,7 +49,7 @@ test('Focused library autocompletes filters and searches without changing editor return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}}); }); await page.getByLabel('Main prompt',{exact:true}).fill('My draft'); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); const category=page.getByLabel('Filter category',{exact:true}); await category.fill('por'); await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible(); @@ -60,7 +71,7 @@ test('Focused library autocompletes filters and searches without changing editor await category.fill('Landscape'); await expect(page.locator('.pm-editor-selection button')).toBeDisabled(); const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;}); - expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '}); + expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '}); await expect(category).toHaveValue('Landscape'); await expect(tags).toHaveValue('studio, '); let recentRequest; @@ -69,14 +80,15 @@ test('Focused library autocompletes filters and searches without changing editor await expect.poll(()=>Boolean(recentRequest)).toBe(true); expect(recentRequest.searchParams.has('category')).toBe(false); expect(recentRequest.searchParams.has('tags')).toBe(false); - await page.getByRole('button',{name:'Hide library',exact:true}).click(); + if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus(); + else await page.getByRole('button',{name:'Hide library',exact:true}).click(); await expect(page.getByRole('listbox')).toBeHidden(); }); -test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => { +test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => { const requests=[]; await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});}); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); await page.getByLabel('Filter category',{exact:true}).fill('New category'); await page.getByLabel('Filter category',{exact:true}).press('Enter'); await expect.poll(()=>requests.length).toBe(1); @@ -88,7 +100,7 @@ test('Focused library can search by filters alone and Enter searches after autoc expect(requests[1].searchParams.get('tags')).toBe('custom'); }); -test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => { +test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => { await page.route('**/prompt_manager/tags/suggest?**', route => { const q = new URL(route.request().url()).searchParams.get('q'); return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}}); @@ -118,7 +130,7 @@ test('Focused completes saved tags with keyboard and pointer and appends commas' await expect(tags).toHaveValue('A brand new tag'); }); -test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => { +test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => { let release; let mode='delayed'; await page.route('**/prompt_manager/tags/suggest?**', async route => { @@ -146,7 +158,7 @@ test('Focused metadata autocomplete ignores stale responses, recovers from error await expect(page.getByRole('listbox')).toHaveCount(0); }); -test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => { +test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => { await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}})); await page.getByText('Metadata · category / tags',{exact:true}).click(); const category=page.getByLabel('Category',{exact:true}); @@ -168,7 +180,7 @@ test('Focused completes one category, allows new values and dismisses suggestion await expect(category).toHaveValue('New category'); }); -test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => { +test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => { await page.evaluate(async()=>{ const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first'); @@ -196,10 +208,10 @@ test('Focused shares suggestion controls safely with another bundled toolkit cop await expect(lists).toHaveCount(0); }); -test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{ +test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{ await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}})); await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft'); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); await page.getByRole('button',{name:'Recent',exact:true}).click(); await page.getByRole('button',{name:/Library portrait/}).click(); await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft'); @@ -214,7 +226,7 @@ test('Focused previews before loading, keeps metadata optional and composes text expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']); }); -test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{ +test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft'); await page.getByText('Metadata · category / tags',{exact:true}).click(); await page.getByLabel('Category',{exact:true}).fill('Saved category'); @@ -228,14 +240,14 @@ test('Focused restores workflow values and disclosures and protects connected te await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft'); }); -test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => { +test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => { let requested; await page.route('**/prompt_manager/search?**', async route => { requested = new URL(route.request().url()); await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}}); }); await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft'); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); const query=page.getByRole('searchbox',{name:'Search saved prompts'}); await query.fill('fox tag:"film grain"'); await query.press('Enter'); @@ -250,7 +262,7 @@ test('Focused searches quoted tags with Enter and explicitly loads metadata', as expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']); }); -test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => { +test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => { let requests=0; let mode='empty'; const malicious=''; @@ -259,7 +271,7 @@ test('Focused reports empty queries, no results, and safely renders backend and if(mode==='http') return route.fulfill({status:503,body:'Unavailable'}); await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}}); }); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); const search=page.getByRole('button',{name:'Search',exact:true}); await search.click(); await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.'); @@ -287,10 +299,10 @@ test('Focused reports empty queries, no results, and safely renders backend and await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.'); }); -test('Focused treats saved prompt content and metadata as text', async ({page}) => { +test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => { const malicious=''; await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'',tags:['']}]}})); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); await page.getByRole('button',{name:'Recent',exact:true}).click(); await page.locator('.pm-editor-result').click(); await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious); @@ -312,9 +324,9 @@ async function deferLibraryRequests(page) { }); } -test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => { +test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => { await deferLibraryRequests(page); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search'); await page.getByRole('button',{name:'Search',exact:true}).click(); await page.getByRole('button',{name:'Recent',exact:true}).click(); @@ -333,9 +345,9 @@ test('Focused keeps the latest result when searches and recent requests overlap' await expect(page.locator('.pm-editor-result')).toHaveText('Latest search'); }); -test('Focused removal aborts work and stops theme and control listeners', async ({page}) => { +test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => { await deferLibraryRequests(page); - await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await openLibrary(page); await page.getByRole('button',{name:'Recent',exact:true}).click(); await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark'); const removed=await page.evaluate(()=>{ @@ -348,11 +360,18 @@ test('Focused removal aborts work and stops theme and control listeners', async await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal')); await expect(page.locator('.pm-editor-result')).toHaveCount(0); await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark'); - await page.getByRole('button',{name:'Hide library',exact:true}).click(); - await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible(); + if (isSplit) { + const count=await page.evaluate(()=>libraryRequests.length); + await page.getByRole('button',{name:'Recent',exact:true}).click(); + expect(await page.evaluate(()=>libraryRequests.length)).toBe(count); + await expect(page.locator('.pm-editor-library')).toBeVisible(); + } else { + await page.getByRole('button',{name:'Hide library',exact:true}).click(); + await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible(); + } }); -test('Focused fields reflow with node width and follow live host themes', async ({page}) => { +test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => { await page.setViewportSize({width:1000,height:1000}); await page.getByText('Composition · prepend / append',{exact:true}).click(); await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';}); @@ -373,7 +392,7 @@ test('Focused fields reflow with node width and follow live host themes', async await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark); }); -test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => { +test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => { await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft '); await page.getByText('Composition · prepend / append',{exact:true}).click(); await page.getByLabel('Prepend',{exact:true}).fill(' trigger '); @@ -382,17 +401,17 @@ test('Focused composed preview preserves main prompt whitespace like queued exec expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail'); }); -test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => { +test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => { expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true); }); -test('Focused expands its main editor when the node gets taller',async({page})=>{ - await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';}); +test(`${variant} expands its main editor when the node gets taller`,async({page})=>{ + await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit); const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox(); expect(editor.height).toBeGreaterThan(400); }); -test('Focused respects the existing Web UI display preference',async({page})=>{ +test(`${variant} respects the existing Web UI display preference`,async({page})=>{ await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}})); await page.evaluate(()=>{ const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated(); @@ -403,9 +422,68 @@ test('Focused respects the existing Web UI display preference',async({page})=>{ expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']); }); -test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{ +test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{ await page.evaluate(()=>{window.open=()=>null;}); await page.getByRole('button',{name:'Web UI ↗',exact:true}).click(); await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups'); await expect(page.locator('.pm-focused')).toBeVisible(); }); + +if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => { + await page.setViewportSize({width:1200,height:1000}); + const root=page.locator('.pm-split'); + await expect(root).toHaveAttribute('aria-label','Split prompt editor'); + await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0); + await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';}); + const compose=root.locator('.pm-editor-workspace > .pm-editor-compose'); + const library=root.locator('.pm-editor-workspace > .pm-editor-library'); + await expect(compose.locator('.pm-editor-main')).toBeVisible(); + let a=await compose.boundingBox(),b=await library.boundingBox(); + expect(Math.abs(a.y-b.y)).toBeLessThan(2); + expect(b.x).toBeGreaterThanOrEqual(a.x+a.width); + await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}})); + await page.getByRole('button',{name:'Recent',exact:true}).click(); + await expect(page.locator('.pm-editor-result')).toContainText('Library example'); + await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library'); + await expect(library).toBeVisible(); + await expect(page.locator('.pm-editor-result')).toContainText('Library example'); + expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library'); + await root.evaluate(element=>{element.style.width='340px';}); + await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true); + expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true); + await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library'); + await expect(library).toBeVisible(); +}); +if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => { + await page.setViewportSize({width:1200,height:1000}); + const root=page.locator('.pm-split'); + await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';}); + const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110)); + const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']})); + await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}})); + await page.getByRole('button',{name:'Recent',exact:true}).click(); + const results=root.locator('.pm-editor-results'); + await expect(results.locator('.pm-editor-result')).toHaveCount(20); + const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height)); + expect(rowHeights.every(height=>height>0)).toBe(true); + const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight})); + expect.soft(resultSize.height).toBeLessThanOrEqual(300); + expect(resultSize.scroll).toBeGreaterThan(resultSize.client); + await results.locator('.pm-editor-result').first().click(); + const preview=root.locator('.pm-editor-selection > .pm-editor-preview'); + await expect(preview).toHaveText(longPrompt); + const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight})); + expect.soft(previewSize.height).toBeLessThanOrEqual(180); + expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client); + await page.getByRole('button',{name:'Use prompt',exact:true}).click(); + await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt); + const bounds=await root.boundingBox(); + for (const selector of ['.pm-editor-compose','.pm-editor-library']) { + const pane=await root.locator(selector).boundingBox(); + expect(pane.y).toBeGreaterThanOrEqual(bounds.y); + expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height); + } + expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true); +}); +}); +} diff --git a/tests/comfyui/focused-node.spec.mjs b/tests/comfyui/focused-node.spec.mjs index 783a98b..1f26193 100644 --- a/tests/comfyui/focused-node.spec.mjs +++ b/tests/comfyui/focused-node.spec.mjs @@ -1,19 +1,21 @@ +import { writeFile } from 'node:fs/promises'; import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import { installHostHooks } from './host-fixture.mjs'; installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']); -async function addFocused(page) { - return page.evaluate(async () => { +async function addEditor(page, type) { + await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type); + return page.evaluate(async type => { const { app } = await import('/scripts/app.js'); - const node = LiteGraph.createNode('PromptManagerFocused'); - if (!node) throw new Error('PromptManagerFocused must be registered by the real host'); + const node = LiteGraph.createNode(type); + if (!node) throw new Error(`${type} must be registered by the real host`); app.graph.add(node); - node.setSize([680, 1050]); + node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]); app.canvas.ds.scale = .8; app.canvas.centerOnNode(node); return node.id; - }); + }, type); } async function model(page, id) { @@ -29,19 +31,26 @@ async function seed(request, text, category = 'Focused host tests', tags = ['foc expect((await response.json()).success).toBe(true); } +async function showLibrary(page) { + const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true }); + if (await button.count()) await button.click(); +} + async function browse(page, text) { const root = page.locator('.pm-focused'); - await root.getByRole('button', { name: 'Browse library', exact: true }).click(); + await showLibrary(page); await root.getByRole('button', { name: 'Recent', exact: true }).click(); await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click(); return root; } -test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => { +for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) { +test.describe(label, () => { +test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => { const definitions = await (await request.get('/object_info')).json(); - const focused = definitions.PromptManagerFocused; + const focused = definitions[type]; expect(focused).toBeDefined(); - expect(focused.display_name).toBe('Prompt Manager (Focused)'); + expect(focused.display_name).toBe(`Prompt Manager (${label})`); expect(focused.output).toEqual(definitions.PromptManager.output); expect(focused.input.required).toEqual(definitions.PromptManager.input.required); for (const name of ['prepend_text', 'append_text']) { @@ -50,10 +59,10 @@ test('Focused definition preserves Classic encoding and exposes composition STRI } }); -test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => { +test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => { const text = `Focused selected ${testInfo.project.name}`; await seed(request, text); - const id = await addFocused(page); + const id = await addEditor(page, type); const root = page.locator('.pm-focused'); const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true }); await expect(root).toBeVisible(); @@ -92,20 +101,20 @@ test('Focused preview, explicit load, composition, renderer changes and workflow expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' }); await expect(editor).toHaveValue(text); await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' }); - await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') }); + await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) }); await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id); await expect(root).toHaveCount(0); }); -test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => { +test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => { const text = `Focused search ${testInfo.project.name}`; await seed(request, text, 'Incoming category', ['incoming-tag']); - const id = await addFocused(page); + const id = await addEditor(page, type); const root = page.locator('.pm-focused'); await root.getByText('Metadata · category / tags', { exact: true }).click(); await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category'); await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag'); - await root.getByRole('button', { name: 'Browse library', exact: true }).click(); + await showLibrary(page); await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text); await root.getByRole('button', { name: 'Search', exact: true }).click(); await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click(); @@ -114,10 +123,10 @@ test('Focused search preview respects metadata opt-out', async ({ page, request await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' }); }); -test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => { +test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => { const text = `Connected library ${testInfo.project.name}`; await seed(request, text); - const id = await addFocused(page); + const id = await addEditor(page, type); const root = page.locator('.pm-focused'); await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main'); await root.getByText('Composition · prepend / append', { exact: true }).click(); @@ -179,11 +188,11 @@ test('Focused connected STRING inputs remain linked through UI actions and reloa await expect(input).toBeEditable(); await expect(input).toHaveValue(value); } - await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') }); + await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) }); }); -test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => { - await addFocused(page); +test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => { + await addEditor(page, type); for (const palette of ['light', 'dark']) { await page.evaluate(async palette => { const { app } = await import('/scripts/app.js'); @@ -191,30 +200,35 @@ test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, }, palette); const root = page.locator('.pm-focused'); await expect(root).toHaveAttribute('data-ca-theme', palette); - await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)'); - await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) }); + if (label === 'Split') { + await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0); + await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)'); + } else { + await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)'); + } + await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) }); } }); -test('Focused Web UI button opens actual standalone pages', async ({ page }) => { - await addFocused(page); +test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => { + await addEditor(page, type); const popupPromise = page.waitForEvent('popup'); await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click(); const popup = await popupPromise; await popup.waitForLoadState('domcontentloaded'); await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/); await expect(popup.locator('body')).toContainText('Prompt'); - await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.'); + await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.'); await popup.goto(new URL('/prompt_manager/gallery', page.url()).href); await expect(popup.locator('#addPromptBtn')).toBeVisible(); await popup.close(); }); -test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => { +test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => { const category = `Host completion category ${testInfo.project.name}`; const tag = `host-completion-${testInfo.project.name}`; await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]); - const id = await addFocused(page); + const id = await addEditor(page, type); const root = page.locator('.pm-focused'); await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow'); await root.getByText('Metadata · category / tags', { exact: true }).click(); @@ -244,7 +258,7 @@ test('Focused metadata completion uses saved categories and tags across renderer expect(bounds.y).toBeGreaterThanOrEqual(0); expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width); expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height); - await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) }); + await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) }); await option.click(); await expect(tagInput).toHaveValue(`keep, ${tag}, `); } @@ -279,20 +293,20 @@ test('Focused metadata completion uses saved categories and tags across renderer await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0); }); -test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => { +test(`${label} library autocomplete filters saved prompts without editing draft metadata`, async ({ page, request }, testInfo) => { const category = `Library category ${testInfo.project.name}`; const tag = `library-filter-${testInfo.project.name}`; const matchingText = `Matching library prompt ${testInfo.project.name}`; await seed(request, matchingText, category, [tag]); await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]); await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]); - const id = await addFocused(page); + const id = await addEditor(page, type); const root = page.locator('.pm-focused'); await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft'); await root.getByText('Metadata · category / tags', { exact: true }).click(); await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category'); await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag'); - await root.getByRole('button', { name: 'Browse library', exact: true }).click(); + await showLibrary(page); const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true }); const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true }); await filterCategory.fill(category.slice(0, -3)); @@ -308,7 +322,7 @@ test('Focused library autocomplete filters saved prompts without editing draft m await filterTags.fill(tag.slice(0, -3)); const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true }); await expect(option).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) }); + await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) }); await option.click(); await expect(filterTags).toHaveValue(`${tag}, `); } @@ -344,3 +358,84 @@ test('Focused library autocomplete filters saved prompts without editing draft m await expect(results.first()).toContainText(matchingText); await expect.poll(() => model(page, id)).toMatchObject(draft); }); + +if (label === 'Split') { + test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => { + for (let index = 0; index < 21; index++) { + await seed(request, `Split layout recent ${testInfo.project.name} ${index}`); + } + const id = await addEditor(page, type); + const root = page.locator('.pm-split'); + await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(id); + node.setSize([900, 650]); + app.canvas.centerOnNode(node); + }, id); + await expect(root).toHaveAttribute('aria-label', 'Split prompt editor'); + const savedProperties = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + return app.graph.getNodeById(id).serialize().properties; + }, id); + expect(savedProperties).toHaveProperty('splitUI'); + expect(savedProperties).not.toHaveProperty('focusedUI'); + await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0); + const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true }); + const library = root.getByRole('region', { name: 'Prompt library', exact: true }); + await expect(library).toBeVisible(); + const captureGeometry = async phase => { + const geometry = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(id); + const root = node._focusedView.element; + const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON(); + const ancestors = []; + for (let element = root; element && element !== document.body; element = element.parentElement) { + const style = getComputedStyle(element); + ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) }); + } + return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') }; + }, id); + const path = testInfo.outputPath(`split-${phase}-geometry.json`); + await writeFile(path, JSON.stringify(geometry, null, 2)); + await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' }); + return geometry; + }; + const beforeRecent = await captureGeometry('before-recent'); + await root.getByRole('button', { name: 'Recent', exact: true }).click(); + const results = library.locator('.pm-editor-results').getByRole('button'); + await expect(results).toHaveCount(20); + const promptText = results.first().locator('span').first(); + const selectedText = await promptText.innerText(); + await results.first().click(); + const use = root.getByRole('button', { name: 'Use prompt', exact: true }); + const afterSelection = await captureGeometry('after-selection'); + await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') }); + expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10); + expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1); + await use.click(); + await expect(editor).toHaveValue(selectedText); + await expect.poll(async () => { + const main = await editor.boundingBox(); + const browser = await library.boundingBox(); + return browser.x >= main.x + main.width; + }).toBe(true); + await page.screenshot({ path: testInfo.outputPath('split-wide.png') }); + await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(id); + node.setSize([480, 1050]); + app.canvas.centerOnNode(node); + }, id); + await expect.poll(async () => { + const main = await editor.boundingBox(); + const browser = await library.boundingBox(); + return browser.y >= main.y + main.height; + }).toBe(true); + await expect(library).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('split-narrow.png') }); + }); +} + +}); +} diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index c895fbc..12f23c0 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -361,3 +361,36 @@ test("Focused workflow-only metadata recovers serialized fields and connected co assert.equal(result.negativePrompt, "avoid blurry watermark"); assert.equal(result.checkpoint, "model.safetensors"); }); + +test("Split API metadata composes linked positive text and preserves negative role", () => { + const prompt = { + 1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } }, + 2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } }, + 3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } }, + 4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } }, + 5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } }, + }; + const result = extractGenerationParams({ prompt }); + assert.equal(result.positivePrompt, "lora trigger a fox 8k"); + assert.equal(result.negativePrompt, "avoid blurry"); +}); + +test("Split workflow-only metadata recovers serialized fields and connected composition", () => { + const workflow = { + nodes: [ + { id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] }, + { id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] }, + { id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] }, + { id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] }, + { id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] }, + ], + links: [ + [1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"], + [3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"], + ], + }; + const result = extractGenerationParams({ workflow }); + assert.equal(result.positivePrompt, "lora trigger a fox 8k"); + assert.equal(result.negativePrompt, "avoid blurry watermark"); + assert.equal(result.checkpoint, "model.safetensors"); +}); diff --git a/tests/test_init_package.py b/tests/test_init_package.py index 4360b8d..53ec2bb 100644 --- a/tests/test_init_package.py +++ b/tests/test_init_package.py @@ -138,11 +138,22 @@ class TestPackageWiring(PackageTestCase): [ "PromptManager", "PromptManagerFocused", + "PromptManagerSplit", "PromptManagerText", "PromptSearchList", ], ) + def test_split_display_name_and_execution_contract_are_registered(self): + node = self.package.NODE_CLASS_MAPPINGS["PromptManagerSplit"] + self.assertEqual( + self.package.NODE_DISPLAY_NAME_MAPPINGS["PromptManagerSplit"], + "Prompt Manager (Split)", + ) + focused = self.package.NODE_CLASS_MAPPINGS["PromptManagerFocused"] + self.assertTrue(issubclass(node, focused)) + self.assertEqual(node.INPUT_TYPES(), focused.INPUT_TYPES()) + def test_db_factory_returns_the_shared_database(self): self.assertIsNotNone(self.package._global_db) self.assertIs(self.package._db_factory(), self.package._global_db) diff --git a/tests/test_prompt_graph.py b/tests/test_prompt_graph.py index 9b910ea..1e6ffda 100644 --- a/tests/test_prompt_graph.py +++ b/tests/test_prompt_graph.py @@ -67,6 +67,19 @@ class TestPositivePromptNodes(unittest.TestCase): del graph["5"] self.assertEqual(run_prompt_nodes(graph), ["2"]) + def test_split_nodes_preserve_positive_roles_and_samplerless_fallback(self): + graph = { + "1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}, + "2": {"class_type": "PromptManagerSplit", "inputs": {"text": "blur"}}, + "5": ksampler("1", "2"), + } + self.assertEqual(positive_prompt_nodes(graph), ["1"]) + self.assertEqual(run_prompt_nodes(graph), ["1"]) + graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}} + self.assertEqual(run_prompt_nodes(graph), []) + del graph["5"] + self.assertEqual(run_prompt_nodes(graph), ["2"]) + def test_negative_only_prompt_manager_is_never_positive(self): graph = { "176": pm("blurry"), diff --git a/tests/test_split_node.py b/tests/test_split_node.py new file mode 100644 index 0000000..9a94bfd --- /dev/null +++ b/tests/test_split_node.py @@ -0,0 +1,54 @@ +"""Split changes presentation while inheriting Focused execution and storage.""" + +import logging +import unittest +import unittest.mock + +from prompt_manager import PromptManagerFocused, PromptManagerSplit + + +class TestSplitNode(unittest.TestCase): + def test_split_preserves_focused_schema_encoding_and_change_detection(self): + self.assertTrue(issubclass(PromptManagerSplit, PromptManagerFocused)) + self.assertEqual( + PromptManagerSplit.INPUT_TYPES(), PromptManagerFocused.INPUT_TYPES() + ) + self.assertEqual( + PromptManagerSplit.RETURN_TYPES, PromptManagerFocused.RETURN_TYPES + ) + self.assertEqual(PromptManagerSplit.FUNCTION, PromptManagerFocused.FUNCTION) + self.assertIs( + PromptManagerSplit.encode_prompt, PromptManagerFocused.encode_prompt + ) + self.assertEqual( + PromptManagerSplit.IS_CHANGED(None, "cat", category="Animals"), + PromptManagerFocused.IS_CHANGED(None, "cat", category="Animals"), + ) + + def test_split_encodes_composition_and_persists_main_text_with_metadata(self): + node = PromptManagerSplit.__new__(PromptManagerSplit) + node.logger = logging.getLogger("test.split") + node.comfyui_integration = unittest.mock.Mock() + node._inject_lora_trigger_words = lambda text: text + node._track_prompt_execution = unittest.mock.Mock(return_value=42) + clip = unittest.mock.Mock() + graph = {"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}} + conditioning, text = node.encode_prompt( + clip, + "cat", + category="Animals", + tags="studio", + prepend_text="trigger", + append_text="detailed", + prompt=graph, + unique_id="1", + ) + self.assertEqual(text, "trigger cat detailed") + clip.tokenize.assert_called_once_with(text) + self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value) + tracked = node._track_prompt_execution.call_args.kwargs + self.assertEqual(tracked["text"], "cat") + self.assertEqual(tracked["category"], "Animals") + self.assertIn("studio", tracked["tags"]) + self.assertEqual(tracked["prompt_graph"], graph) + self.assertEqual(tracked["unique_id"], "1") diff --git a/tests/test_usage_tracking.py b/tests/test_usage_tracking.py index 3caf60d..f7070fb 100644 --- a/tests/test_usage_tracking.py +++ b/tests/test_usage_tracking.py @@ -136,6 +136,16 @@ class TestQueueHook(DbTestCase): self.assertEqual(self._runs(positive), 2) self.assertEqual(self._runs(negative), 0) + def test_split_queued_runs_count_only_positive_even_when_cached(self): + positive, negative = self._save("a cat"), self._save("blurry") + graph = workflow() + for node_id in ("134", "176"): + graph[node_id]["class_type"] = "PromptManagerSplit" + self._queue(graph) + self._queue(graph) + self.assertEqual(self._runs(positive), 2) + self.assertEqual(self._runs(negative), 0) + def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self): positive = self._save("a cat") self._queue(workflow()) diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index cf3096a..acc3b2e 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -12,7 +12,7 @@ import re from typing import Any, Dict, List, Optional PROMPT_MANAGER_TYPES = frozenset( - {"PromptManager", "PromptManagerText", "PromptManagerFocused"} + {"PromptManager", "PromptManagerText", "PromptManagerFocused", "PromptManagerSplit"} ) # Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index b92acda..59045c6 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -3,8 +3,8 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js')); -const { installFocusedNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js')); -const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused']); +const { installFocusedNode, installSplitNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js')); +const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']); // ============================================================================ // Top Bar Button Integration (similar to rgthree-comfy approach) @@ -292,8 +292,9 @@ app.registerExtension({ }, async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === 'PromptManagerFocused') { - installFocusedNode(nodeType, app, api, parseSearchQuery); + if (nodeData.name === 'PromptManagerFocused' || nodeData.name === 'PromptManagerSplit') { + const install = nodeData.name === 'PromptManagerSplit' ? installSplitNode : installFocusedNode; + install(nodeType, app, api, parseSearchQuery); return; } if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") { diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 04945f9..8e35fc1 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -236,6 +236,7 @@ PromptManager: PROMPT_MANAGER_WIDGETS, PromptManagerText: PROMPT_MANAGER_WIDGETS, PromptManagerFocused: PROMPT_MANAGER_WIDGETS, + PromptManagerSplit: PROMPT_MANAGER_WIDGETS, CheckpointLoader: ["config_name", "ckpt_name"], UNETLoader: ["unet_name", "weight_dtype"], }; diff --git a/web/js/focused-node.js b/web/js/focused-node.js index 5befb9b..138904c 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -11,7 +11,13 @@ function loadStyles() { const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link); } -export function installFocusedNode(nodeType, app, api, parseQuery) { +export function installSplitNode(nodeType, app, api, parseQuery) { + installFocusedNode(nodeType, app, api, parseQuery, {layout:'split'}); +} + +export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'focused'} = {}) { + const split = layout === 'split'; + const uiKey = split ? 'splitUI' : 'focusedUI'; const prototype = nodeType.prototype; const created = prototype.onNodeCreated; const removed = prototype.onRemoved; @@ -30,13 +36,18 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4]; widgets.set(name, widget); } - const element = document.createElement('div'); element.className = 'ca-tools pm-focused'; - element.setAttribute('aria-label','Focused prompt editor'); + const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}`; + element.setAttribute('aria-label',split ? 'Split prompt editor' : 'Focused prompt editor'); loadStyles(); mountTheme(element, app.ui?.settings, scope); const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar'; const title = document.createElement('strong'); title.textContent = 'Prompt Manager'; const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false'); - const web = createButton('Web UI ↗'); toolbar.append(title, browse, web); + const web = createButton('Web UI ↗'); toolbar.append(title); + if (!split) toolbar.append(browse); + toolbar.append(web); + const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace'; + const editor = document.createElement('div'); editor.className = 'pm-editor-compose'; + workspace.append(editor); let webDisplayMode = 'popup'; this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal}) .then(response => response.ok ? response.json() : null) @@ -55,17 +66,19 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { else delete textWidget.inputEl; }); const mainField = createField('Main prompt', main, `${editorId}-text`); + mainField.classList.add('pm-editor-main'); const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.'; const controls = new Map([['text', main]]); const details = new Map(); const completions = new Map(); - element.append(toolbar, mainField, connected); + element.append(toolbar, workspace); + editor.append(mainField, connected); function disclosure(key, label) { const section = document.createElement('details'); section.className = 'pm-editor-details'; - const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); element.append(section); details.set(key,section); + const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); editor.append(section); details.set(key,section); scope.listen(section, 'toggle', () => { if (!section.open) completions.forEach(completion => completion.close()); - if (!scope.signal.aborted) thisNode.properties.focusedUI = snapshot(); + if (!scope.signal.aborted) thisNode.properties[uiKey] = snapshot(); }); return section; } @@ -112,7 +125,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' '); library.refreshAvailability(); } - const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, onUse:(prompt,includeMetadata) => { + const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties[uiKey] = snapshot();}, onUse:(prompt,includeMetadata) => { if (isConnected('text')) return; write('text',prompt.text); if (includeMetadata) { @@ -121,14 +134,14 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { } render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus(); }}); - element.append(library.element,footer); + workspace.append(library.element); element.append(footer); function snapshot() { return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))}; } function restore() { - const state = thisNode.properties.focusedUI || {}; + const state = thisNode.properties[uiKey] || {}; details.forEach((section,name) => {section.open = Boolean(state[name]);}); - library.element.hidden = !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen))); + library.element.hidden = !split && !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen))); library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render(); } controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';})); @@ -136,7 +149,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { library.element.hidden = !library.element.hidden; library.closeCompletions(); browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden)); - this.properties.focusedUI = snapshot(); if (!library.element.hidden) library.focus(); + this.properties[uiKey] = snapshot(); if (!library.element.hidden) library.focus(); }); scope.listen(web,'click',() => { const url = api.fileURL('/prompt_manager/web'); @@ -146,11 +159,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { }); this._focusedView = {element,render,restore,snapshot}; this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360}); - this.size = [550,650]; this.setSize(this.size); restore(); + this.size = [split ? 900 : 550,650]; this.setSize(this.size); restore(); }; prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; }; prototype.serialize = function (...args) { - if (this._focusedView) this.properties.focusedUI = this._focusedView.snapshot(); + if (this._focusedView) this.properties[uiKey] = this._focusedView.snapshot(); return serialize?.apply(this,args); }; prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; }; diff --git a/web/js/prompt-editor.css b/web/js/prompt-editor.css index befa9c4..0698e78 100644 --- a/web/js/prompt-editor.css +++ b/web/js/prompt-editor.css @@ -1,9 +1,10 @@ -.pm-focused { inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; } +.pm-focused { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; } .pm-focused [hidden] { display: none !important; } .pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; } .pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; } -.pm-focused > .ca-field { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; } +.pm-editor-workspace, .pm-editor-compose { display: contents; } +.pm-focused .pm-editor-main { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; } .pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; } .pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; } .pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; } @@ -17,11 +18,23 @@ .pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; } .pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; } .pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; } -.pm-editor-results { display: grid; gap: 6px; max-block-size: 300px; overflow: auto; } +.pm-editor-results { display: grid; grid-auto-rows: max-content; align-content: start; gap: 6px; max-block-size: 300px; overflow: auto; } .pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; } .pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); } .pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; } -.pm-editor-result > span:first-child { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; } +.pm-editor-result > span:first-child { flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; } .pm-editor-selection { display: grid; gap: 10px; } .pm-editor-selection .pm-editor-row { justify-content: space-between; } @container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } } +/* Contain intrinsic sizing so Nodes 2.0 keeps the widget within the node. */ +.pm-split { contain: size; } +.pm-split .pm-editor-workspace { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; flex: 1; min-inline-size: 0; } +.pm-split .pm-editor-compose { display: flex; flex-direction: column; gap: 10px; min-inline-size: 0; min-block-size: 300px; } +.pm-split .pm-editor-library { min-inline-size: 0; align-content: start; } +.pm-split .pm-editor-preview { box-sizing: border-box; max-block-size: 180px; overflow: auto; } +@container pm-editor (inline-size > 720px) { + .pm-split .pm-editor-workspace { grid-template-columns: minmax(0,1.3fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr); min-block-size: 0; } + .pm-split .pm-editor-compose, .pm-split .pm-editor-library { min-block-size: 0; overflow: auto; } + .pm-split .pm-editor-library { border-block-start: 0; border-inline-start: 1px solid var(--ca-line); padding-block-start: 0; padding-inline-start: 12px; } + .pm-split .pm-editor-fields { grid-template-columns: minmax(0,1fr); } +}