diff --git a/README.md b/README.md index 6142e6fc..946967d1 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,12 @@ Both nodes support the same metadata fields: Both nodes will automatically save prompts to the database and link any generated images to the prompt. +- **Save to library** (`save_to_library`, on by default; "Save to library" in the Focused, Split and Pair editors): + turn it off while iterating and PromptManager ignores that node's runs. The prompt is not saved, the run is not + counted and its images are not linked (a scan skips them too), while the node's output is unchanged. Workflows + saved before this switch existed load with it on. If any PromptManager node feeding a sampler's positive input + has it off, none of that run's images are linked, because the image cannot show which sampler produced it. + ### 🖼️ Using the Output-Wide Gallery The comprehensive gallery system provides complete access to all your ComfyUI output: diff --git a/prompt_manager.py b/prompt_manager.py index 2dcf0031..b7bcdc1a 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -26,13 +26,13 @@ except ImportError: InputTypeDict = dict try: - from .prompt_manager_base import PromptManagerBase + from .prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase except ImportError: import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from prompt_manager_base import PromptManagerBase + from prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase class PromptManager(PromptManagerBase, ComfyNodeABC): @@ -115,6 +115,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): ) }, ), + "save_to_library": SAVE_TO_LIBRARY_INPUT, }, "hidden": { # The queued graph and this node's id: used to tell the positive @@ -152,6 +153,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): append_text: str = "", prompt: Optional[Dict[str, Any]] = None, unique_id: Optional[str] = None, + save_to_library: bool = True, ) -> Tuple[Any]: """ Encode the text prompt and save it to the database. @@ -164,6 +166,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): search_text: Text to search for in past prompts prepend_text: Text to prepend to the main prompt append_text: Text to append to the main prompt + save_to_library: Off: nothing is saved, counted or tracked (#90) Returns: Tuple containing the conditioning for the diffusion model and the final @@ -205,7 +208,9 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): # Save prompt to database and set execution context for gallery tracking prompt_id = None extended_tags = [] - if storage_text and storage_text.strip(): + if not save_to_library: + self._skip_not_saved(prompt, unique_id) + elif storage_text and storage_text.strip(): self.logger.debug(f"Processing prompt text: {storage_text[:100]}...") # Add prepend/append info to tags if they exist @@ -249,6 +254,11 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): tokens = clip.tokenize(encoding_text) conditioning = clip.encode_from_tokens_scheduled(tokens) + self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}") + result = (conditioning, encoding_text) + if not save_to_library: + return result # #90: no metadata registration, no history text + # Register with ComfyUI integration for standard metadata compatibility node_id = f"promptmanager_{int(time.time() * 1000)}" self.comfyui_integration.register_prompt( @@ -262,9 +272,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): "append_text": append_text.strip() if append_text else None, }, ) - - self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}") - result = (conditioning, encoding_text) record_runtime_context( type(self).__name__, text, prepend_text, append_text, encoding_text ) diff --git a/prompt_manager_base.py b/prompt_manager_base.py index 62977b1f..921e1e2a 100644 --- a/prompt_manager_base.py +++ b/prompt_manager_base.py @@ -23,7 +23,7 @@ try: from .utils.image_monitor import get_image_monitor from .utils.prompt_tracker import get_prompt_tracker from .utils.hashing import generate_prompt_hash - from .utils.prompt_graph import resolve_text, run_prompt_nodes + from .utils.prompt_graph import resolve_text, run_prompt_nodes, save_setting from .utils.run_keys import executing_run_id, prompt_node_key from .utils.usage_tracking import PENDING_FIRST_USE from .utils.cached_runs import ( @@ -42,7 +42,7 @@ except ImportError: from utils.image_monitor import get_image_monitor from utils.prompt_tracker import get_prompt_tracker from utils.hashing import generate_prompt_hash - from utils.prompt_graph import resolve_text, run_prompt_nodes + from utils.prompt_graph import resolve_text, run_prompt_nodes, save_setting from utils.run_keys import executing_run_id, prompt_node_key from utils.usage_tracking import PENDING_FIRST_USE from utils.cached_runs import ( @@ -54,6 +54,22 @@ except ImportError: ) +# The optional input every PromptManager node declares last (#90): declared +# last so no widget value of a workflow saved before it shifts. +SAVE_TO_LIBRARY_INPUT = ( + "BOOLEAN", + { + "default": True, + "label_on": "save", + "label_off": "skip", + "tooltip": ( + "Save this node's prompt to the library, count its runs and link its " + "images. Off: PromptManager ignores the run; the output is unchanged." + ), + }, +) + + class PromptManagerBase: """Mixin providing shared prompt management logic for ComfyUI nodes. @@ -156,7 +172,9 @@ class PromptManagerBase: resolved = resolve_text(prompt_graph, node_id) return { "is_run_prompt": node_id in run_prompt_nodes(prompt_graph), + # The hook skips a switch it cannot read (#90): the node counts "seen_at_queue": resolved is not None + and save_setting(prompt_graph, node_id) is True and generate_prompt_hash(resolved) == generate_prompt_hash(text), "known": True, } @@ -245,6 +263,24 @@ class PromptManagerBase: "run_id": executing_run_id(), } + def _skip_not_saved(self, prompt_graph: Any = None, unique_id: Any = None) -> None: + """``save_to_library`` is off: nothing is saved, counted or tracked (#90). + + A literal off makes the node no run prompt (``utils.prompt_graph``), so + no image looks up its run key. A switch wired from a node the graph + cannot read keeps the node a run prompt: its run key is marked like an + empty editor's, so an earlier run's entry of that key never links this + run's images. + """ + message = ( + "Save to library is off for this node: its prompt is not saved, the " + "run is not counted and its images are not linked" + ) + if isinstance(prompt_graph, dict) and unique_id is not None: + self._mark_untracked(prompt_graph, unique_id, message) + else: + self.logger.info(message) + def _skip_empty_prompt(self, prompt_graph: Any, unique_id: Any) -> None: """The ``text`` editor is empty: save and track nothing (#342). diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index df62d633..d406e23e 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -22,7 +22,11 @@ try: ) from .utils.hashing import generate_prompt_hash from .utils.pair_execution import record_pair_execution - from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components + from .utils.prompt_graph import ( + intrinsic_pair_nodes, + run_prompt_components, + save_setting, + ) from .utils.prompt_pair_resolver import resolve_text_input from .utils.usage_tracking import ( PENDING_FIRST_USE, @@ -41,7 +45,11 @@ except ImportError: ) from utils.hashing import generate_prompt_hash from utils.pair_execution import record_pair_execution - from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components + from utils.prompt_graph import ( + intrinsic_pair_nodes, + run_prompt_components, + save_setting, + ) from utils.prompt_pair_resolver import resolve_text_input from utils.usage_tracking import ( PENDING_FIRST_USE, @@ -90,8 +98,13 @@ class PromptManagerPair(PromptManagerFocused): prompt: Optional[Dict[str, Any]] = None, unique_id: Optional[str] = None, extra_pnginfo: Optional[Dict[str, Any]] = None, + save_to_library: bool = True, ) -> Tuple[Any, Any, str, str]: - """Encode both sides once; database and tracking failures never block CLIP.""" + """Encode both sides once; database and tracking failures never block CLIP. + + With ``save_to_library`` off nothing is saved, counted, tracked or + recorded (#90); the four outputs are the same. + """ if clip is None: raise RuntimeError("ERROR: clip input is invalid: None") if not isinstance(text, str) or not isinstance(negative_text, str): @@ -109,6 +122,10 @@ class PromptManagerPair(PromptManagerFocused): negative_conditioning = clip.encode_from_tokens_scheduled( clip.tokenize(negative_text) ) + result = positive_conditioning, negative_conditioning, positive, negative_text + if not save_to_library: + self._skip_not_saved(prompt, unique_id) + return result record_pair_execution( extra_pnginfo, unique_id, @@ -169,7 +186,6 @@ class PromptManagerPair(PromptManagerFocused): ) except Exception as error: self.logger.warning(f"Could not register paired metadata: {error}") - result = positive_conditioning, negative_conditioning, positive, negative_text record_runtime_context( type(self).__name__, text, @@ -219,6 +235,9 @@ class PromptManagerPair(PromptManagerFocused): resolved_negative = ( resolve_text_input(prompt, node_id, "negative_text") if known else None ) + if known and save_setting(prompt, node_id) is not True: + # The hook skips a switch it cannot read (#90): the node counts + resolved_positive = resolved_negative = None positive_seen = resolved_positive is not None and generate_prompt_hash( resolved_positive ) == generate_prompt_hash(text) diff --git a/prompt_manager_text.py b/prompt_manager_text.py index 6e316f98..c6e3f070 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -26,13 +26,13 @@ except ImportError: InputTypeDict = dict try: - from .prompt_manager_base import PromptManagerBase + from .prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase except ImportError: import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from prompt_manager_base import PromptManagerBase + from prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase class PromptManagerText(PromptManagerBase, ComfyNodeABC): @@ -115,6 +115,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): ), }, ), + "save_to_library": SAVE_TO_LIBRARY_INPUT, }, "hidden": { # The queued graph and this node's id: used to tell the positive @@ -149,6 +150,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): append_text: str = "", prompt: Optional[Dict[str, Any]] = None, unique_id: Optional[str] = None, + save_to_library: bool = True, ) -> Tuple[str]: """ Process the text prompt and save it to the database. @@ -160,6 +162,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): search_text: Text to search for in past prompts prepend_text: Text to prepend to the main prompt append_text: Text to append to the main prompt + save_to_library: Off: nothing is saved, counted or tracked (#90) Returns: Tuple containing the final processed text string @@ -184,7 +187,9 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): # Save prompt to database and set execution context for gallery tracking prompt_id = None extended_tags = [] - if storage_text and storage_text.strip(): + if not save_to_library: + self._skip_not_saved(prompt, unique_id) + elif storage_text and storage_text.strip(): self.logger.debug(f"Processing prompt text: {storage_text[:100]}...") # Add prepend/append info to tags if they exist @@ -220,6 +225,11 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): else: self._skip_empty_prompt(prompt, unique_id) + self.logger.debug(f"Text processing completed: {final_text[:100]}...") + result = (final_text,) + if not save_to_library: + return result # #90: no metadata registration, no history text + # Register with ComfyUI integration for standard metadata compatibility node_id = f"promptmanagertext_{int(time.time() * 1000)}" self.comfyui_integration.register_prompt( @@ -233,9 +243,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): "append_text": append_text.strip() if append_text else None, }, ) - - self.logger.debug(f"Text processing completed: {final_text[:100]}...") - result = (final_text,) record_runtime_context( type(self).__name__, text, prepend_text, append_text, final_text ) diff --git a/py/api/admin.py b/py/api/admin.py index a96d07d2..e010e3fb 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -52,6 +52,7 @@ try: extract_generation_params, parse_page_json, ) + from ...utils.prompt_graph import metadata_opted_out, side_opted_out from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt from ...utils.review_capture import reviewable except ImportError: @@ -67,6 +68,7 @@ except ImportError: extract_generation_params, parse_page_json, ) + from utils.prompt_graph import metadata_opted_out, side_opted_out from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt from utils.review_capture import reviewable @@ -260,6 +262,11 @@ SCAN_DETAILS = { "failed": "Error: the prompt could not be stored", "error": "Error: the file could not be processed (see the server log)", "found": "Metadata found, but no prompt text to store", + "not_saved": "Skipped · its PromptManager node does not save to the library", + "not_saved_wired": ( + "Skipped · its Save to library switch is wired from another node, so only " + "the run itself can link it" + ), None: "No prompt metadata in the file", } @@ -1720,10 +1727,34 @@ class AdminRoutesMixin: except (ValueError, TypeError): # Preserve malformed metadata for conservative resolver rejection. pass + if metadata_opted_out(canonical): + # Save to library was off for the run's prompt: no prompt, link, + # recorded-run fallback or Needs review item (#90) + return "not_saved" + if metadata_opted_out(canonical, unreadable=True): + # The switch is wired from a node the file cannot show: only the + # live run (the node saw the value) can link it, never a scan + return "not_saved_wired" if not canonical.get("prompt") and not canonical.get("parameters"): return self._link_recorded_run(media_file, canonical, detailed, history) _with_file_size(canonical, media_file) resolution = resolve_prompt_pair(canonical) + if resolution["state"] == "resolved" and side_opted_out( + canonical.get("prompt"), resolution["negative"] + ): + # Only the negative's node is off: store the positive alone (#90) + prompt_text = ( + resolution["positive"] + .get("storage_text", resolution["positive"]["text"]) + .strip() + ) + status = self._store_scanned_prompt(media_file, canonical, prompt_text) + if not detailed or status not in SCAN_TITLED_DETAILS: + return status + return { + "status": status, + "title": self._scan_title(media_file, prompt_text), + } if blank_positive(resolution): # Only prepend/append text: no stored prompt can own it (#342) resolution = {**resolution, "state": "unresolved"} diff --git a/tests/browser/classic-node.spec.mjs b/tests/browser/classic-node.spec.mjs index bd627e22..0f6cdead 100644 --- a/tests/browser/classic-node.spec.mjs +++ b/tests/browser/classic-node.spec.mjs @@ -19,7 +19,7 @@ test.beforeEach(async ({page, browserName}) => { await import('/extensions/promptmanager/prompt_manager.js'); class Node { constructor(){ - this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')})); + this.widgets=[...['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true}]; this.widgets_values=[]; this.inputs=[]; } addDOMWidget(name,type,element){document.body.append(element);return {element};} @@ -36,6 +36,14 @@ test.afterEach(async ({page,browserName}) => { expect(page.classicErrors).toEqual([]); }); +test('Classic loads an older workflow with Save to library on and keeps a saved off (#90)',async({page})=>{ + // A workflow saved before the switch held the search UI's slot ('') at the switch's index + const loaded=await page.evaluate(()=>{const widget=node.widgets.find(w=>w.name==='save_to_library');widget.value='';node.configure({});return widget.value;}); + expect(loaded).toBe(true); + const off=await page.evaluate(()=>{const widget=node.widgets.find(w=>w.name==='save_to_library');widget.value=false;node.configure({});return [widget.value,node.serialize().widgets_values[4]];}); + expect(off).toEqual([false,false]); +}); + test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{ await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}})); await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';}); diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index 4f2026bb..6ab2818c 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -26,7 +26,7 @@ test.beforeEach(async({page,browserName})=>{ 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,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[550,700];} + constructor(){this.widgets=[...['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true,options:{}}];this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}})),{name:'save_to_library',type:'BOOLEAN',link:null,widget:{name:'save_to_library'}}];this.properties={};this.size=[550,700];} findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} 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};} @@ -41,6 +41,32 @@ test.beforeEach(async({page,browserName})=>{ }); test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);}); +test(`${variant} Save to library switch saves its value and loads older workflows on (#90)`, async ({page}) => { + const toggle=page.getByRole('checkbox',{name:'Save to library',exact:true}); + await expect(toggle).toBeChecked(); + expect(await page.evaluate(()=>node.widgets.map(widget=>widget.name))).toEqual(['text','category','tags','search_text','prepend_text','append_text','save_to_library']); + // The native switch is drawn in the toolbar only: the host keeps it hidden and zero-height, like the text fallbacks + expect(await page.evaluate(()=>{const widget=node.widgets.find(item=>item.name==='save_to_library');return {type:widget.type,hidden:widget.hidden,size:widget.computeSize()};})).toEqual({type:'hidden',hidden:true,size:[0,-4]}); + expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').computeSize())).toEqual([0,-4]); + await toggle.uncheck(); + await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Save to library is off: runs of this node are not added to the library.'); + const saved=await page.evaluate(()=>node.serialize()); + expect(saved.widgets_values[6]).toBe(false); + await page.evaluate(saved=>node.configure(saved),saved); + await expect(toggle).not.toBeChecked(); + await page.getByLabel('Main prompt',{exact:true}).fill('Typed while off'); + await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Save to library is off: runs of this node are not added to the library.'); + // A workflow saved before the switch held the editor's own slot ('') at its index + await page.evaluate(()=>node.configure({widgets_values:['Old text','','','','','',''],properties:{}})); + await expect(toggle).toBeChecked(); + expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='save_to_library').value)).toBe(true); + await toggle.check(); + await page.getByLabel('Main prompt',{exact:true}).fill('Typed while on'); + await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Main prompt is saved when this node runs.'); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='save_to_library').link=9;node.onConnectionsChange?.();}); + await expect(toggle).toBeDisabled(); +}); + 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']}})); @@ -257,7 +283,7 @@ test(`${variant} restores workflow values and disclosures and protects connected test(`${variant} offers text as a link socket, keeps typed text while linked and through a reload (#343)`,async({page})=>{ const sockets=()=>page.evaluate(()=>Object.fromEntries(node.inputs.map(input=>[input.name,input.widget?.name??null]))); // The hidden text widget no longer owns the input, so the host draws it as a socket; the other widget inputs are unchanged. - expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text'}); + expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text',save_to_library:'save_to_library'}); expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('text'); expect(await page.evaluate(()=>node.findInputByType('CLIP').slot.name)).toBe('clip'); const main=page.getByRole('textbox',{name:'Main prompt',exact:true}); diff --git a/tests/browser/pair-node.spec.mjs b/tests/browser/pair-node.spec.mjs index 2d554a84..e1ccda8a 100644 --- a/tests/browser/pair-node.spec.mjs +++ b/tests/browser/pair-node.spec.mjs @@ -17,7 +17,7 @@ test.beforeEach(async({page,browserName})=>{ window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})}; await import('/extensions/promptmanager/prompt_manager.js'); class Node { - constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_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,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[1100,700];} + constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=[...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true,options:{}}];this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}})),{name:'save_to_library',type:'BOOLEAN',link:null,widget:{name:'save_to_library'}}];this.properties={};this.size=[1100,700];} findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};} @@ -34,6 +34,18 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName); async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();} async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);} +test('paired editor Save to library switch keeps the widget order and loads older workflows on (#90)',async({page})=>{ + await expect(page.locator('.pm-pair')).toBeVisible(); + const toggle=page.getByRole('checkbox',{name:'Save to library',exact:true}); + await expect(toggle).toBeChecked(); + expect(await page.evaluate(()=>node.widgets.map(widget=>widget.name))).toEqual(['text','negative_text','category','tags','search_text','prepend_text','append_text','save_to_library']); + await toggle.uncheck(); + expect(await page.evaluate(()=>node.serialize().widgets_values[7])).toBe(false); + await expect(page.locator('.pm-pair > .pm-editor-status[role=status]').last()).toHaveText('Save to library is off: runs of this node are not added to the library.'); + await page.evaluate(()=>node.configure({widgets_values:['P','N','','','','','',''],properties:{}})); + await expect(toggle).toBeChecked(); +}); + test('paired editor previews both texts before explicitly replacing both',async({page})=>{ await expect(page.locator('.pm-pair')).toBeVisible(); await draft(page); diff --git a/tests/comfyui/execution-text-link.spec.mjs b/tests/comfyui/execution-text-link.spec.mjs index 20e60de6..813a0345 100644 --- a/tests/comfyui/execution-text-link.spec.mjs +++ b/tests/comfyui/execution-text-link.spec.mjs @@ -16,8 +16,8 @@ installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocu // `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node, // whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's // TextGenerate -> PreviewAny chain, whose text is only known when the node runs. -function build(page, { kind, positive, negative, prefix }) { - return page.evaluate(async ({ kind, positive, negative, prefix }) => { +function build(page, { kind, positive, negative, prefix, saveToLibrary }) { + return page.evaluate(async ({ kind, positive, negative, prefix, saveToLibrary }) => { const { app } = await import('/scripts/app.js'); app.graph.clear(); const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; }; @@ -28,6 +28,7 @@ function build(page, { kind, positive, negative, prefix }) { const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; }; set('text', positive.typed); if (negative) set('negative_text', negative.typed); + if (saveToLibrary !== undefined) prompt.widgets.find(item => item.name === 'save_to_library').value = saveToLibrary; const drive = (name, side, y) => { if (!side.via) return; if (side.via === 'legacy') { @@ -73,7 +74,7 @@ function build(page, { kind, positive, negative, prefix }) { await prompt._pmTextSerializerReady; const { output, workflow } = await app.graphToPrompt(); return { output, workflow, promptNode: String(prompt.id) }; - }, { kind, positive, negative, prefix }); + }, { kind, positive, negative, prefix, saveToLibrary }); } async function run(execution, page, testCase, built) { @@ -195,6 +196,47 @@ for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'], }); } +// #90: with Save to library off the run still produces its image, but PromptManager saves, counts and links nothing, +// and the run's history records no prompt text for it. +for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']) { + test(`${kind} with Save to library off saves, counts and links nothing (#90)`, async ({ execution, page }) => { + test.setTimeout(90000); + const text = unique(`Not saved ${kind}`); + const negative = kind === 'PromptManagerPair' ? { typed: unique('Not saved negative') } : undefined; + const prefix = `notsaved/${randomUUID()}`; + const { output, workflow, promptNode } = await build(page, { kind, positive: { typed: text }, negative, prefix, saveToLibrary: false }); + expect(output[promptNode].inputs.save_to_library).toBe(false); + const id = randomUUID(); + const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } }); + expect(response.ok(), await response.text()).toBe(true); + let item; + await expect.poll(async () => { + item = (await (await execution.request.get(`/history/${id}`)).json())[id]; + return item?.status?.status_str ?? null; + }, { timeout: 20000 }).toBe('success'); + let result; + await expect.poll(async () => { + result = await readExecutionRuns(execution.databasePath, id); + const run = result.rows[0]; + return Boolean(run && result.captures[run.run_id]); + }, { timeout: 10000, message: 'the run reaches SQLite' }).toBe(true); + const run = result.rows[0]; + for (const context of result.captures[run.run_id].contexts || []) { + expect(context.positive_prompt_id ?? null).toBeNull(); + expect(context.pair_id ?? null).toBeNull(); + } + expect((result.usage[run.run_id] || []).every(entry => entry.run_count === 0)).toBe(true); + const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename); + expect(files.length).toBeGreaterThan(0); + await scan(execution); + expect(Object.values(await linkedPrompts(execution, files))).toEqual(files.map(() => null)); + for (const query of [text, negative?.typed].filter(Boolean)) { + const found = await (await execution.request.get(`/prompt_manager/search?text=${encodeURIComponent(query)}`)).json(); + expect(found.results ?? [], query).toEqual([]); + } + }); +} + // #347: a cached PromptManager node counts every job, as an executing one does. Jobs after the first change only // the sampler seed, so ComfyUI serves the PromptManager node (and its PreviewAny source) from its output cache. const COUNTS = `import json, sqlite3, sys, pathlib diff --git a/tests/comfyui/text-link.spec.mjs b/tests/comfyui/text-link.spec.mjs index 6bfaf01a..612d34b4 100644 --- a/tests/comfyui/text-link.spec.mjs +++ b/tests/comfyui/text-link.spec.mjs @@ -370,6 +370,7 @@ test('workflows saved before #343 load unchanged and their linked text keeps wor const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node])); const reloaded = app.graph.serialize(); return { + saves: Object.fromEntries(Object.entries(output).filter(([, node]) => node.class_type.startsWith('PromptManager')).map(([id, node]) => [id, node.inputs.save_to_library])), values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])), links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])), widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])), @@ -377,8 +378,16 @@ test('workflows saved before #343 load unchanged and their linked text keeps wor }, workflow); for (const node of workflow.nodes) { // Every saved widget value comes back as it was saved, except the typed text main lost under a link (null). - if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values); + // #90: the last saved value is the editor's own slot (''), where the Save to library switch is now: it loads on. + if (node.widgets_values?.[0] === null) continue; + if (!node.type.startsWith('PromptManager')) { expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values); continue; } + const saved = node.widgets_values.length - 1; + expect(result.values[node.id].slice(0, saved), `${node.type} ${node.id}`).toEqual(node.widgets_values.slice(0, saved)); + expect(result.values[node.id][saved], `${node.type} ${node.id} save_to_library`).toBe(true); } + // Every PromptManager node of the old workflow queues with Save to library on. + for (const save of Object.values(result.saves)) expect(save).toBe(true); + expect(Object.keys(result.saves).length).toBeGreaterThan(0); expect(result.links).toMatchObject({ 1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' }, 4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' }, diff --git a/tests/js/save-to-library.test.js b/tests/js/save-to-library.test.js new file mode 100644 index 00000000..7d64aa0e --- /dev/null +++ b/tests/js/save-to-library.test.js @@ -0,0 +1,68 @@ +// #90: the node's save_to_library switch (web/js/save-to-library.js). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/save-to-library.js'); + +function classicNode(saved) { + const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text', 'save_to_library', 'prompt_manager_search_ui']; + const widgets = names.map(name => ({name, value: name === 'save_to_library' ? true : ''})); + const node = {widgets, widgets_values: saved ? [...saved] : undefined}; + // What a host configure does: each saved value goes to the widget at its index. + (saved || []).forEach((value, index) => { if (widgets[index]) widgets[index].value = value; }); + return node; +} + +test('an older workflow stored the editor slot where the switch is now: it loads on', async () => { + const {normalizeSaveWidget, savesToLibrary} = await source; + const node = classicNode(['a cat', 'c', 't', '', '', '', '']); + assert.equal(node.widgets[6].value, '', 'the host put the old DOM slot value into the switch'); + assert.equal(normalizeSaveWidget(node), true); + assert.equal(node.widgets[6].value, true); + assert.equal(node.widgets_values[6], true); + assert.equal(savesToLibrary(node), true); + assert.deepEqual(node.widgets_values.slice(0, 6), ['a cat', 'c', 't', '', '', ''], 'no earlier value moved'); +}); + +test('null, undefined and missing slots load on; saved booleans are kept', async () => { + const {normalizeSaveWidget, savesToLibrary} = await source; + for (const value of [null, undefined, 0, 'false']) { + const node = classicNode(['a', '', '', '', '', '', value]); + normalizeSaveWidget(node); + assert.equal(savesToLibrary(node), true, String(value)); + } + const short = classicNode(['a', '', '', '', '', '']); + assert.equal(normalizeSaveWidget(short), false); + assert.equal(savesToLibrary(short), true); + const off = classicNode(['a', '', '', '', '', '', false]); + assert.equal(normalizeSaveWidget(off), false); + assert.equal(savesToLibrary(off), false); +}); + +test('a node without the widget (an older backend) saves', async () => { + const {normalizeSaveWidget, savesToLibrary} = await source; + assert.equal(savesToLibrary({widgets: [{name: 'text', value: ''}]}), true); + assert.equal(normalizeSaveWidget({widgets: []}), false); + assert.equal(savesToLibrary(undefined), true); +}); + +test('the switch moves after the editor fallbacks so every frontend has one order', async () => { + const {placeSaveWidgetLast} = await source; + const widgets = ['text', 'category', 'tags', 'search_text', 'save_to_library', 'prepend_text', 'append_text'].map(name => ({name})); + const node = {widgets}; + assert.equal(placeSaveWidgetLast(node), true); + assert.deepEqual(node.widgets.map(widget => widget.name), ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text', 'save_to_library']); + assert.equal(placeSaveWidgetLast(node), false, 'already last'); + assert.equal(placeSaveWidgetLast({widgets: [{name: 'text'}]}), false); +}); + +test('setting the switch updates the widget, its saved value and notifies the host', async () => { + const {setSaveToLibrary, savesToLibrary} = await source; + const calls = []; + const node = classicNode(['a', '', '', '', '', '', true]); + node.widgets[6].callback = value => calls.push(value); + setSaveToLibrary(node, false); + assert.equal(savesToLibrary(node), false); + assert.equal(node.widgets_values[6], false); + assert.deepEqual(calls, [false]); + setSaveToLibrary({widgets: []}, false); +}); diff --git a/tests/test_cached_runs.py b/tests/test_cached_runs.py index d6ea4954..b337b6a6 100644 --- a/tests/test_cached_runs.py +++ b/tests/test_cached_runs.py @@ -210,13 +210,24 @@ class JobCase(unittest.TestCase): node = self.node(kind) self.context = (self.context[0], index) clip = mock.Mock() + # The node receives its switch as the queued graph holds it (#90) + save = g[node_id]["inputs"].get("save_to_library", True) if kind == "PromptManagerText": - return node.process_text(text, prompt=g, unique_id=node_id) + return node.process_text( + text, prompt=g, unique_id=node_id, save_to_library=save + ) if kind == "PromptManagerPair": return node.encode_prompt( - clip, text, negative_text=negative, prompt=g, unique_id=node_id + clip, + text, + negative_text=negative, + prompt=g, + unique_id=node_id, + save_to_library=save, ) - return node.encode_prompt(clip, text, prompt=g, unique_id=node_id) + return node.encode_prompt( + clip, text, prompt=g, unique_id=node_id, save_to_library=save + ) def cache(self, node_id, *results): """Store executed results (one per list index) as ComfyUI's cache entry.""" @@ -313,6 +324,35 @@ class TestCachedJobsCount(JobCase): self.job(g, cached=["pm"]) self.assertEqual(self.db.search_prompts(), []) + def test_disabled_node_then_cached_jobs_counts_0(self): + # #90 x #347: a node with Save to library off records no receipt and is + # no run prompt, so its cached jobs replay nothing. The prompt exists + # (an enabled run saved it), so any replayed count would show. + for kind in ( + "PromptManager", + "PromptManagerFocused", + "PromptManagerSplit", + "PromptManagerText", + "PromptManagerPair", + ): + with self.subTest(kind=kind): + text = f"{LLM} not saved by {kind}" + negative = "blurry" if kind == "PromptManagerPair" else "" + self.first(graph(kind, negative_text=negative), text, negative) + before = (self.count(text), self.pair_count(text, negative)) + + def off(seed): + g = graph(kind, negative_text=negative, seed=seed) + g["pm"]["inputs"]["save_to_library"] = False + return g + + self.first(off(1), text, negative) + self.job(off(2), cached=["pm"]) + self.job(off(3), cached=["pm"]) + after = (self.count(text), self.pair_count(text, negative)) + self.assertEqual(before[0], 1) + self.assertEqual(after, before) + def test_alternating_upstream_values_each_count_their_own_prompt(self): g = graph() self.first(g, text="value A") diff --git a/tests/test_save_to_library.py b/tests/test_save_to_library.py new file mode 100644 index 00000000..3d8bee47 --- /dev/null +++ b/tests/test_save_to_library.py @@ -0,0 +1,508 @@ +"""#90 item 3: a node's ``save_to_library`` switch. + +Off: PromptManager ignores the run completely (no prompt saved, no run count, +no image link), while the node still returns its normal outputs. On (the +default, and every workflow or image made before the switch existed): as before. +""" + +import logging +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from tests.test_prompt_attribution import MonitorCase, NodeCase + from tests.test_execution_contexts import load_module +except ImportError: # discovered with tests/ as the top-level directory + from test_prompt_attribution import MonitorCase, NodeCase + from test_execution_contexts import load_module + +from prompt_manager import PromptManager, PromptManagerFocused, PromptManagerSplit +from prompt_manager_pair import PromptManagerPair +from prompt_manager_text import PromptManagerText +from prompt_search_list import PromptSearchList +from utils.hashing import generate_prompt_hash +from utils.prompt_graph import ( + intrinsic_pair_nodes, + positive_opt_outs, + run_prompt_components, + run_prompt_nodes, + save_setting, +) +from utils.run_keys import prompt_node_key +from utils.usage_tracking import ( + PendingFirstUse, + QueuedRuns, + handle_queued_prompt, + on_dequeue, + queued_pair_runs, + queued_prompt_runs, +) + +POSITIVE = "a lighthouse at dusk" +NEGATIVE = "blurry" +NODE_CLASSES = ( + PromptManager, + PromptManagerText, + PromptManagerFocused, + PromptManagerSplit, + PromptManagerPair, +) + + +def run_graph(save=None, kind="PromptManager", negative_save=None): + """A PromptManager positive feeding a KSampler; ``save`` None leaves it out.""" + inputs = {"text": POSITIVE, "clip": ["4", 1]} + if save is not None: + inputs["save_to_library"] = save + negative_inputs = {"text": NEGATIVE, "clip": ["4", 1]} + if negative_save is not None: + negative_inputs["save_to_library"] = negative_save + return { + "1": {"class_type": kind, "inputs": inputs}, + "2": {"class_type": "PromptManager", "inputs": negative_inputs}, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, + "3": { + "class_type": "KSampler", + "inputs": { + "model": ["4", 0], + "positive": ["1", 0], + "negative": ["2", 0], + "latent_image": ["6", 0], + }, + }, + "6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}}, + } + + +def pair_graph(save=None): + inputs = {"text": POSITIVE, "negative_text": NEGATIVE, "clip": ["4", 1]} + if save is not None: + inputs["save_to_library"] = save + return { + "1": {"class_type": "PromptManagerPair", "inputs": inputs}, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, + "3": { + "class_type": "KSampler", + "inputs": { + "model": ["4", 0], + "positive": ["1", 0], + "negative": ["1", 1], + "latent_image": ["6", 0], + }, + }, + "6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}}, + } + + +class TestInputDeclaration(unittest.TestCase): + def test_every_prompt_manager_node_declares_a_boolean_defaulting_on(self): + for cls in NODE_CLASSES: + optional = cls.INPUT_TYPES()["optional"] + kind, options = optional["save_to_library"] + self.assertEqual(kind, "BOOLEAN", cls.__name__) + self.assertIs(options["default"], True, cls.__name__) + # Declared last: no saved widget value of an older workflow shifts + self.assertEqual(list(optional)[-1], "save_to_library", cls.__name__) + + def test_search_list_has_no_switch(self): + inputs = PromptSearchList.INPUT_TYPES() + self.assertNotIn("save_to_library", inputs.get("optional", {})) + self.assertNotIn("save_to_library", inputs.get("required", {})) + + +class TestSaveSetting(unittest.TestCase): + def test_missing_input_is_on(self): + self.assertIs(save_setting(run_graph(), "1"), True) + + def test_literals_follow_comfyui_boolean_coercion(self): + for value, expected in ( + (True, True), + (False, False), + ("", False), + (0, False), + ({"__value__": False}, False), + ({"__value__": True}, True), + ): + with self.subTest(value=value): + self.assertIs(save_setting(run_graph(save=value), "1"), expected) + + def test_link_to_primitive_boolean_is_read(self): + for value in (True, False): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": value}} + self.assertIs(save_setting(graph, "1"), value) + + def test_link_to_an_unknown_node_is_not_knowable(self): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + self.assertIsNone(save_setting(graph, "1")) + self.assertIsNone(save_setting(run_graph(save=["missing", 0]), "1")) + + +class TestGraphRoles(unittest.TestCase): + def test_opted_out_positive_is_not_a_run_prompt(self): + graph = run_graph(save=False) + self.assertEqual(run_prompt_nodes(graph), []) + self.assertEqual(run_prompt_components(graph), []) + self.assertEqual(positive_opt_outs(graph), ["1"]) + + def test_enabled_positive_is_unchanged(self): + for save in (None, True): + graph = run_graph(save=save) + self.assertEqual(run_prompt_nodes(graph), ["1"]) + self.assertEqual(positive_opt_outs(graph), []) + + def test_opted_out_negative_is_no_positive_opt_out(self): + graph = run_graph(negative_save=False) + self.assertEqual(run_prompt_nodes(graph), ["1"]) + self.assertEqual(positive_opt_outs(graph), []) + + def test_walk_stops_at_an_opted_out_node(self): + graph = run_graph(save=False) + graph["1"]["inputs"]["text"] = ["7", 0] + graph["7"] = {"class_type": "PromptManagerText", "inputs": {"text": "x"}} + self.assertEqual(run_prompt_nodes(graph), []) + + def test_sampler_less_fallback_skips_opted_out_nodes(self): + graph = { + "1": { + "class_type": "PromptManagerText", + "inputs": {"text": "a", "save_to_library": False}, + }, + "2": {"class_type": "PromptManagerText", "inputs": {"text": "b"}}, + } + self.assertEqual(run_prompt_nodes(graph), ["2"]) + self.assertEqual(run_prompt_components(graph), [("2", "text")]) + self.assertEqual(positive_opt_outs(graph), ["1"]) + + def test_opted_out_pair_is_not_an_intrinsic_pair(self): + self.assertEqual(intrinsic_pair_nodes(pair_graph()), ["1"]) + self.assertEqual(intrinsic_pair_nodes(pair_graph(save=False)), []) + + +class TestQueueHook(unittest.TestCase): + def test_opted_out_node_is_never_stashed(self): + self.assertEqual(queued_prompt_runs(run_graph(save=False)), []) + self.assertEqual(queued_pair_runs(pair_graph(save=False)), []) + + def test_unknown_setting_is_left_to_the_node(self): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + self.assertEqual(queued_prompt_runs(graph), []) + pair = pair_graph(save=["9", 0]) + pair["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + self.assertEqual(queued_pair_runs(pair), []) + + def test_primitive_boolean_on_still_counts(self): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": True}} + self.assertEqual( + queued_prompt_runs(graph), [(generate_prompt_hash(POSITIVE), POSITIVE)] + ) + + def test_dequeue_counts_nothing_for_a_cached_opted_out_node(self): + """A cached node never executes: only the queued graph can say it is off.""" + db = mock.Mock() + db.get_prompt_by_hash.return_value = {"id": 5} + queued, pending = QueuedRuns(), PendingFirstUse() + request = {"prompt": run_graph(save=False), "prompt_id": "job-1"} + handle_queued_prompt(request, queued, pair_queued=QueuedRuns()) + on_dequeue("job-1", queued, lambda: db, pending, pair_queued=QueuedRuns()) + db.record_prompt_use.assert_not_called() + self.assertEqual(pending.consume(generate_prompt_hash(POSITIVE)), 0) + + +class TestNodesSaveNothing(NodeCase): + def run_node(self, cls, graph, save): + node = self.node(cls) + node.record_pair = mock.Mock() + kwargs = {"prompt": graph, "unique_id": "1", "save_to_library": save} + if cls is PromptManagerText: + return node, node.process_text(POSITIVE, **kwargs) + if cls is PromptManagerPair: + return node, node.encode_prompt( + self.clip(), POSITIVE, negative_text=NEGATIVE, **kwargs + ) + return node, node.encode_prompt(self.clip(), POSITIVE, **kwargs) + + def test_off_saves_counts_and_tracks_nothing_but_returns_outputs(self): + for cls in NODE_CLASSES: + with self.subTest(node=cls.__name__): + graph = ( + pair_graph(save=False) + if cls is PromptManagerPair + else run_graph(save=False, kind=cls.__name__) + ) + node, result = self.run_node(cls, graph, False) + self.assertEqual(self.count_rows("prompts"), 0) + self.assertEqual(self.count_rows("prompt_pairs"), 0) + self.assertEqual(self.tracker.keyed_snapshot(), {}) + self.assertEqual(self.tracker._prompt_queue, []) + node.comfyui_integration.register_prompt.assert_not_called() + if cls is PromptManagerText: + self.assertEqual(result, (POSITIVE,)) + elif cls is PromptManagerPair: + self.assertEqual(result[0], ("cond", POSITIVE)) + self.assertEqual(result[1], ("cond", NEGATIVE)) + self.assertEqual(result[2:], (POSITIVE, NEGATIVE)) + else: + self.assertEqual(result, (("cond", POSITIVE), POSITIVE)) + + def test_off_without_graph_context_still_saves_nothing(self): + node = self.node(PromptManager) + node.encode_prompt(self.clip(), POSITIVE, save_to_library=False) + self.assertEqual(self.count_rows("prompts"), 0) + self.assertEqual(self.tracker._prompt_queue, []) + + def test_off_leaves_an_existing_prompt_uncounted(self): + prompt_id = self.save(POSITIVE) + self.run_node(PromptManager, run_graph(save=False), False) + row = self.db.get_prompt_by_id(prompt_id) + self.assertFalse(row.get("run_count") or 0) + + def test_on_is_unchanged(self): + self.run_node(PromptManager, run_graph(save=True), True) + self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE))) + self.assertIn( + prompt_node_key(run_graph(save=True), "1"), self.tracker.keyed_snapshot() + ) + + def test_unknown_setting_on_at_run_time_counts_at_execution(self): + """The hook could not read a wired switch, so the node counts its run.""" + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + prompt_id = self.save(POSITIVE) + self.run_node(PromptManager, graph, True) + self.assertEqual(self.db.get_prompt_by_id(prompt_id)["run_count"], 1) + + +class TestWiredSwitchOffMarksItsRun(NodeCase): + """Review finding: a wired switch off at run time keeps the node a run prompt.""" + + def wired_graph(self): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + return graph + + def test_an_earlier_entry_of_the_key_never_stands_in(self): + graph = self.wired_graph() + key = prompt_node_key(graph, "1") + node = self.node(PromptManager) + node.encode_prompt( + self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=True + ) + node.encode_prompt( + self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=False + ) + latest, _run, _count = self.tracker.keyed_snapshot()[key] + self.assertTrue(latest.get("placeholder")) + + def test_literal_off_marks_nothing(self): + node = self.node(PromptManager) + node.encode_prompt( + self.clip(), + POSITIVE, + prompt=run_graph(save=False), + unique_id="1", + save_to_library=False, + ) + self.assertEqual(self.tracker.keyed_snapshot(), {}) + + +class TestMonitorLeavesOptedOutFilesUnlinked(MonitorCase): + def test_existing_prompt_is_not_linked_from_metadata(self): + self.save(POSITIVE) + self.assertIsNone(self.process({"prompt": run_graph(save=False)})) + + def test_find_prompt_reports_opted_out(self): + self.save(POSITIVE) + state, prompt = self.handler._find_prompt_from_metadata( + {"prompt": run_graph(save=False)} + ) + self.assertEqual((state, prompt), ("opted_out", None)) + + def test_mixed_positive_settings_leave_the_file_unlinked(self): + self.save(POSITIVE) + graph = run_graph(save=True) + graph["8"] = { + "class_type": "PromptManager", + "inputs": {"text": "other", "save_to_library": False}, + } + graph["5"] = { + "class_type": "KSampler", + "inputs": { + "model": ["4", 0], + "positive": ["8", 0], + "negative": ["2", 0], + "latent_image": ["6", 0], + }, + } + self.assertIsNone(self.process({"prompt": graph})) + + def test_workflow_only_metadata_with_switch_off_is_opted_out(self): + self.save(POSITIVE) + metadata = { + "text_encoder_nodes": [ + { + "type": "PromptManager", + "inputs": [], + "widgets_values": [POSITIVE, "", "", "", "", "", False], + } + ] + } + state, _prompt = self.handler._find_prompt_from_metadata(metadata) + self.assertEqual(state, "opted_out") + + def test_workflow_only_ignores_muted_nodes_and_reads_subgraphs(self): + from utils.prompt_graph import metadata_opted_out + + node = { + "type": "PromptManager", + "widgets_values": [POSITIVE, "", "", "", "", "", False], + } + self.assertFalse( + metadata_opted_out({"workflow": {"nodes": [{**node, "mode": 4}]}}) + ) + self.assertTrue( + metadata_opted_out( + { + "workflow": { + "nodes": [], + "definitions": {"subgraphs": [{"nodes": [node]}]}, + } + } + ) + ) + + def test_unknown_setting_is_left_to_the_run(self): + self.save(POSITIVE) + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + state, _prompt = self.handler._find_prompt_from_metadata({"prompt": graph}) + self.assertEqual(state, "unknown") + + def test_enabled_positive_still_links(self): + prompt_id = self.save(POSITIVE) + self.assertEqual(self.process({"prompt": run_graph(save=True)}), prompt_id) + + +class TestLinkKeepsOptedOutNegativeOut(MonitorCase): + def setUp(self): + super().setUp() + # The real link, not the recording stub of MonitorCase + del self.handler.link_image_to_prompt + + def test_opted_out_negative_is_never_stored_as_a_pair(self): + prompt_id = self.save(POSITIVE) + metadata = {"prompt": run_graph(save=True, negative_save=False)} + self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata) + self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE))) + self.assertEqual(self.count_pairs(), 0) + linked = ( + self.db.model.get_connection() + .execute("SELECT prompt_id FROM generated_images") + .fetchall() + ) + self.assertEqual([row[0] for row in linked], [prompt_id]) + + def test_opted_out_positive_links_nothing(self): + prompt_id = self.save(POSITIVE) + metadata = {"prompt": run_graph(save=False)} + self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata) + count = ( + self.db.model.get_connection() + .execute("SELECT COUNT(*) FROM generated_images") + .fetchone()[0] + ) + self.assertEqual(count, 0) + + def count_pairs(self): + conn = self.db.model.get_connection() + return conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0] + + +class TestScanSkipsOptedOutFiles(NodeCase): + def setUp(self): + super().setUp() + from py.api import admin + + self.mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin) + self.mixin.db = self.db + self.mixin.logger = logging.getLogger("test.save_to_library.scan") + self.file = Path(self.tmp.name) / "out.png" + + def test_opted_out_file_stores_nothing(self): + status = self.mixin._ingest_scanned_file( + self.file, {"prompt": run_graph(save=False)} + ) + self.assertEqual(status, "not_saved") + self.assertEqual(self.count_rows("prompts"), 0) + self.assertEqual(self.count_rows("generated_images"), 0) + + def test_opted_out_file_is_never_queued_for_review(self): + ambiguous = [] + graph = run_graph(save=False) + graph["5"] = dict(graph["3"]) # a second sampler: ambiguous otherwise + graph["5"]["inputs"] = {**graph["3"]["inputs"], "positive": ["2", 0]} + status = self.mixin._ingest_scanned_file( + self.file, {"prompt": graph}, ambiguous=ambiguous + ) + self.assertEqual(status, "not_saved") + self.assertEqual(ambiguous, []) + + def test_opted_out_negative_stores_the_positive_only(self): + status = self.mixin._ingest_scanned_file( + self.file, {"prompt": run_graph(save=True, negative_save=False)} + ) + self.assertEqual(status, "added") + self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE))) + self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE))) + self.assertEqual(self.count_rows("prompt_pairs"), 0) + + def test_wired_switch_is_left_to_the_live_run(self): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + status = self.mixin._ingest_scanned_file(self.file, {"prompt": graph}) + self.assertEqual(status, "not_saved_wired") + self.assertEqual(self.count_rows("prompts"), 0) + + def test_status_has_a_scan_detail(self): + from py.api import admin + + group, row, _line = admin._scan_outcome(self.file, [], "not_saved") + self.assertEqual(group, "no_metadata") + self.assertIn("does not save", row["detail"]) + + +class TestHistoryRecordsNoText(unittest.TestCase): + def setUp(self): + self.m = load_module("execution_context_graph") + + def test_opted_out_role_has_no_sources_and_no_text(self): + recipe = self.m.project_contexts(run_graph(save=False), None).recipes[0] + self.assertEqual(recipe.positive.sources, ()) + self.assertIsNone(recipe.positive.inferred) + self.assertEqual(recipe.positive.reason, "not_saved") + # The enabled negative is recorded as before + self.assertEqual(recipe.negative.inferred.raw.value, NEGATIVE) + + def test_unknown_setting_records_no_text_either(self): + graph = run_graph(save=["9", 0]) + graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}} + recipe = self.m.project_contexts(graph, None).recipes[0] + self.assertIsNone(recipe.positive.inferred) + self.assertEqual(recipe.positive.reason, "not_saved") + + def test_enabled_role_is_unchanged(self): + recipe = self.m.project_contexts(run_graph(save=True), None).recipes[0] + self.assertEqual(recipe.positive.inferred.raw.value, POSITIVE) + self.assertEqual(len(recipe.positive.sources), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 2cb9587c..bf5b1c0d 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -35,15 +35,16 @@ test('execution script discovers only the new matrix and preserves all 78 origin assert.match(executions, /\[nodes2-start\]/); // + the fresh-host library location case, + #342 image attribution, + #343 linked text, // + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects), - // + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects) - assert.match(executions, /Total: 118 tests in 5 files/); + // + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects), + // + #90 Save to library off (five node types) + assert.match(executions, /Total: 128 tests in 5 files/); for (const project of ['classic-start', 'nodes2-start']) { const cases = executions.split('\n').filter(line => line.includes(`[${project}]`)); assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15); assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8); assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1); assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3); - assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 32); + assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 37); } assert.doesNotMatch(executions, /prompt-manager.spec/); }); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index de6c2416..dd070d4b 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -107,6 +107,12 @@ "functions": 98, "statements": 98 }, + "save-to-library.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "scan-progress.js": { "lines": 98, "branches": 98, diff --git a/utils/execution_context_capture.py b/utils/execution_context_capture.py index 3a07985f..0fd22d24 100644 --- a/utils/execution_context_capture.py +++ b/utils/execution_context_capture.py @@ -11,6 +11,7 @@ import weakref from .execution_context_budget import ContextBudget, PayloadLease, SealedContext from .execution_context_graph import ( + NOT_SAVED, GraphProjection, GraphRole, SourceRef, @@ -325,6 +326,8 @@ class ContextCapture: nonlocal ambiguous if role.reason == "source-transformed": return _unknown("unsupported") + if role.reason == NOT_SAVED: + return _unknown("unavailable") if not role.sources: return role.inferred or _unknown("unsupported") values = [ diff --git a/utils/execution_context_graph.py b/utils/execution_context_graph.py index 740cfe43..ee884a34 100644 --- a/utils/execution_context_graph.py +++ b/utils/execution_context_graph.py @@ -3,8 +3,13 @@ import re from dataclasses import dataclass from .execution_contexts import PromptSnapshot, TextEvidence, text_size +from .prompt_graph import save_setting from .prompt_pair_resolver import _DEFAULT_ADAPTERS +# A role whose PromptManager source has save_to_library off, or wired from a +# node the graph cannot read: no text is recorded for it (#90) +NOT_SAVED = "not_saved" + @dataclass(frozen=True) class SourceRef: @@ -332,6 +337,11 @@ class _Projector: self.transformed = False # Only the capture-source-to-consumer path determines transforms. sources = self.discover_sources(value, "conditioning") + if any( + save_setting(self.graph, source.node_id) is not True + for source in sources + ): + return GraphRole((), None, NOT_SAVED) snapshot, _ = self.text(value, "conditioning") return GraphRole( sources, snapshot, "source-transformed" if self.transformed else "" diff --git a/utils/image_monitor.py b/utils/image_monitor.py index 7c70d876..78cfcb10 100644 --- a/utils/image_monitor.py +++ b/utils/image_monitor.py @@ -37,7 +37,13 @@ from .hashing import generate_prompt_hash from .metadata_extractor import ComfyUIMetadataExtractor from .parallel import note_refusal, refused_at_shutdown from .logging_config import get_logger -from .prompt_graph import resolve_text, run_prompt_nodes +from .prompt_graph import ( + metadata_opted_out, + resolve_text, + run_prompt_nodes, + save_setting, + side_opted_out, +) from .prompt_pair_resolver import resolve_prompt_pair from .run_keys import image_run_keys from .usage_tracking import survive_switch @@ -450,6 +456,12 @@ class ImageGenerationHandler(FileSystemEventHandler): # that produced this image, independent of node caching and of any # negative-prompt PromptManager nodes in the workflow. state, current_prompt = self._find_prompt_from_metadata(metadata) + if state == "opted_out": + self.logger.info( + "Save to library is off for this run's prompt, skipping image: " + f"{image_path}" + ) + return if state == "foreign": # Another workflow saved this image: it belongs to no prompt of # ours, and must not consume a batch queue entry. @@ -715,16 +727,27 @@ class ImageGenerationHandler(FileSystemEventHandler): ``("linked", prompt)`` with a context dict holding 'id' and 'text'; ``("foreign", None)`` when the workflow has no positive PromptManager node, so the image belongs to no prompt of ours; + ``("opted_out", None)`` when a PromptManager node that would be the + run's prompt has its ``save_to_library`` switch off (#90): the image + is linked to no prompt, by no fallback; ``("unknown", None)`` when metadata can't decide (missing, a batch item whose text came from another node, or a lookup failure) and the caller's queue and tracker fallbacks apply. """ if not metadata: return "unknown", None + if metadata_opted_out(metadata): + return "opted_out", None + graph = metadata.get("prompt") + if isinstance(graph, dict) and any( + save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph) + ): + # A switch wired from a node the graph cannot read: only the node's + # own run (it saw the real value) can link the image (#90) + return "unknown", None prompt_text = None resolution = resolve_prompt_pair(metadata) - graph = metadata.get("prompt") if resolution["state"] == "resolved" and isinstance(graph, dict): positive = resolution["positive"] path = positive["provenance"].get("path", []) @@ -865,13 +888,23 @@ class ImageGenerationHandler(FileSystemEventHandler): metadata: Extracted metadata from the image file (workflow, parameters, etc.) """ + if metadata_opted_out(metadata): + # Reached from a run or history: the file's own graph says no (#90) + self.logger.info( + f"Save to library is off for {os.path.basename(image_path)}; " + "left unlinked" + ) + return try: resolution = resolve_prompt_pair(metadata) + graph = metadata.get("prompt") if isinstance(metadata, dict) else None + # An opted-out negative is never stored: link the positive alone + pair_allowed = not side_opted_out(graph, resolution.get("negative")) def link(): # One write; run again on the moved library when a Move # library switched while it waited (#249 S6b). - if resolution["state"] == "resolved": + if resolution["state"] == "resolved" and pair_allowed: positive = resolution["positive"] expected = self.db_manager.get_prompt_by_hash( generate_prompt_hash( diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index fc1ee362..b21021af 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -21,6 +21,11 @@ PROMPT_MANAGER_TYPES = frozenset( } ) +# A node's switch (#90): False means PromptManager ignores that node's runs +SAVE_INPUT = "save_to_library" +# Core nodes whose output is a known boolean: (class_type) -> input holding it +_BOOLEAN_SOURCES = {"PrimitiveBoolean": "value"} + # Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted MAX_VISITS = 500 # Longest text resolve_text will build; StringConcatenate chains can double @@ -59,6 +64,116 @@ def _inputs(node: Any) -> Dict[str, Any]: return inputs if isinstance(inputs, dict) else {} +def _as_boolean(value: Any) -> bool: + """ComfyUI's coercion of a BOOLEAN input before the node runs.""" + if isinstance(value, dict) and "__value__" in value: + value = value["__value__"] + return bool(value) + + +def save_setting(graph: Any, node_id: Any) -> Optional[bool]: + """A PromptManager node's ``save_to_library`` as the node will receive it. + + True when the input is missing (every workflow and image from before the + switch), the literal coerced like ComfyUI does, the value of a linked core + ``PrimitiveBoolean``, and None when the switch is wired from a node whose + output cannot be known from the graph. + """ + node = graph.get(str(node_id)) if isinstance(graph, dict) else None + inputs = _inputs(node) + if SAVE_INPUT not in inputs: + return True + value = inputs[SAVE_INPUT] + if not _is_link(value): + return _as_boolean(value) + source = graph.get(str(value[0])) + if not isinstance(source, dict) or value[1] != 0: + return None + key = _BOOLEAN_SOURCES.get(str(source.get("class_type", ""))) + source_inputs = _inputs(source) + if key is None or key not in source_inputs or _is_link(source_inputs[key]): + return None + return _as_boolean(source_inputs[key]) + + +def opted_out(graph: Any, node_id: Any) -> bool: + """Whether a PromptManager node's switch is off: PromptManager ignores it.""" + return save_setting(graph, node_id) is False + + +def side_opted_out(graph: Any, side: Any) -> bool: + """Whether a resolved side's path passes a PromptManager node that is off. + + ``side`` is a resolver side (``positive`` or ``negative`` of a resolved + pair): its text must not be stored when any PromptManager node on its + provenance path has its switch off or wired from an unreadable node (#90). + """ + if not isinstance(graph, dict) or not isinstance(side, dict): + return False + provenance = side.get("provenance") + path = provenance.get("path") if isinstance(provenance, dict) else None + for step in path if isinstance(path, list) else []: + node_id = step.get("node_id") if isinstance(step, dict) else None + node = graph.get(str(node_id)) if node_id is not None else None + if ( + isinstance(node, dict) + and node.get("class_type") in PROMPT_MANAGER_TYPES + and save_setting(graph, node_id) is not True + ): + return True + return False + + +def _workflow_node_opted_out(node: Any) -> bool: + """A saved-workflow PromptManager node whose widget values hold ``False``. + + No other PromptManager widget holds a boolean, so the value is the switch. + """ + if not isinstance(node, dict): + return False + if (node.get("type") or node.get("class_type")) not in PROMPT_MANAGER_TYPES: + return False + if node.get("mode") in (2, 4): + return False # muted or bypassed: it did not run + values = node.get("widgets_values") + return isinstance(values, list) and any(value is False for value in values) + + +def metadata_opted_out(metadata: Any, *, unreadable: bool = False) -> bool: + """Whether a file's metadata says its run's prompt does not save (#90). + + The executed graph (``prompt``) decides when present: a node of + ``positive_opt_outs``, or with ``unreadable`` also a run prompt whose + switch is wired from a node the graph cannot read. Without one, the + saved workflow (``workflow`` nodes or the extractor's + ``text_encoder_nodes``) is opted out when a PromptManager node stores + ``False``. + """ + if not isinstance(metadata, dict): + return False + graph = metadata.get("prompt") + if isinstance(graph, dict): + if positive_opt_outs(graph): + return True + return unreadable and any( + save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph) + ) + workflow = metadata.get("workflow") + nodes = list(metadata.get("text_encoder_nodes") or []) + if isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list): + nodes.extend(workflow["nodes"]) + definitions = workflow.get("definitions") + subgraphs = ( + definitions.get("subgraphs") if isinstance(definitions, dict) else None + ) + for subgraph in subgraphs if isinstance(subgraphs, list) else []: + inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None + nodes.extend(inner if isinstance(inner, list) else []) + # Without the executed graph the node's role is unknown: any opted-out + # PromptManager node opts the file out (never a guessed link). + return any(_workflow_node_opted_out(node) for node in nodes[:MAX_VISITS]) + + def _positive_roots(graph: Dict[str, Any]) -> List[Any]: """Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...). @@ -100,11 +215,21 @@ def has_sampler(graph: Any) -> bool: def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]: - """Node/input identities reaching actual sampler positive wires.""" + """Node/input identities reaching actual sampler positive wires. + + A node whose switch is off is not one, and nothing upstream of it is + followed (#90): see ``positive_opt_outs``. + """ + return _walk_positive(graph)[0] + + +def _walk_positive(graph: Any) -> Tuple[List[Tuple[str, str]], List[str]]: + """(components of enabled nodes, opted-out nodes) on sampler positive wires.""" if not isinstance(graph, dict): - return [] + return [], [] found: List[Tuple[str, str]] = [] + skipped: List[str] = [] seen = set() stack = list(reversed(_positive_roots(graph))) while stack and len(seen) < MAX_VISITS: @@ -117,6 +242,10 @@ def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]: if not isinstance(node, dict): continue if node.get("class_type") in PROMPT_MANAGER_TYPES: + if opted_out(graph, node_id): + if node_id not in skipped: + skipped.append(node_id) + continue field = "text" if node.get("class_type") == "PromptManagerPair": field = { @@ -132,7 +261,33 @@ def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]: found.append(identity) continue stack.extend(reversed(_upstream_links(_inputs(node)))) - return found + return found, skipped + + +def _sampler_less_nodes(graph: Dict[str, Any]) -> List[str]: + """Every PromptManager node of a graph without a recognisable sampler.""" + return [ + str(node_id) + for node_id, node in graph.items() + if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES + ] + + +def positive_opt_outs(graph: Any) -> List[str]: + """PromptManager nodes with their switch off that would be a run's prompt. + + Those on a sampler's positive wires, or, in a graph without a recognisable + sampler, any. A run with one of them links no file to any prompt (#90): + the file's graph cannot always say which sampler wrote it. + """ + if not isinstance(graph, dict): + return [] + found, skipped = _walk_positive(graph) + if found or skipped or has_sampler(graph): + return skipped + return [ + node_id for node_id in _sampler_less_nodes(graph) if opted_out(graph, node_id) + ] def positive_prompt_nodes(graph: Any) -> List[str]: @@ -148,13 +303,13 @@ def positive_prompt_nodes(graph: Any) -> List[str]: def run_prompt_components(graph: Any) -> List[Tuple[str, str]]: """Actual positive components, retaining standalone main-editor fallback.""" - components = positive_prompt_components(graph) - if components or not isinstance(graph, dict) or has_sampler(graph): + components, skipped = _walk_positive(graph) + if components or skipped or not isinstance(graph, dict) or has_sampler(graph): return components return [ - (str(node_id), "text") - for node_id, node in graph.items() - if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES + (node_id, "text") + for node_id in _sampler_less_nodes(graph) + if not opted_out(graph, node_id) ] @@ -165,16 +320,16 @@ def run_prompt_nodes(graph: Any) -> List[str]: say), every PromptManager node counts, as before 3.2.4, so an exotic workflow never silently loses usage counting or image linking. When a sampler exists and no PromptManager node feeds its positive input, nothing counts: the only - PromptManager nodes are negative prompts. + PromptManager nodes are negative prompts. A node whose switch is off never + counts (#90). """ - positive = positive_prompt_nodes(graph) - if positive or not isinstance(graph, dict) or has_sampler(graph): - return positive - return [ - str(node_id) - for node_id, node in graph.items() - if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES - ] + return list( + dict.fromkeys( + node_id + for node_id, field in run_prompt_components(graph) + if field == "text" + ) + ) def _join_text_inputs( @@ -270,7 +425,18 @@ def resolve_text(graph: Any, node_id: str) -> Optional[str]: def intrinsic_pair_nodes(graph: Any) -> List[str]: - """Paired nodes whose matching outputs feed the same sampler, or standalone.""" + """Paired nodes whose matching outputs feed the same sampler, or standalone. + + A pair whose switch is off is never one (#90). + """ + return [ + node_id + for node_id in _intrinsic_pair_nodes(graph) + if not opted_out(graph, node_id) + ] + + +def _intrinsic_pair_nodes(graph: Any) -> List[str]: if not isinstance(graph, dict) or len(graph) > MAX_VISITS: return [] if not has_sampler(graph): diff --git a/utils/usage_tracking.py b/utils/usage_tracking.py index 6fb6d39c..761019da 100644 --- a/utils/usage_tracking.py +++ b/utils/usage_tracking.py @@ -30,7 +30,12 @@ from typing import Any, Callable, List, Optional, Tuple try: from .hashing import generate_prompt_hash from .logging_config import get_logger - from .prompt_graph import intrinsic_pair_nodes, resolve_text, run_prompt_components + from .prompt_graph import ( + intrinsic_pair_nodes, + resolve_text, + run_prompt_components, + save_setting, + ) from .prompt_pair_resolver import resolve_text_input from .pair_execution import clear_pair_execution from .queue_observers import subscribe_dequeue @@ -41,6 +46,7 @@ except ImportError: intrinsic_pair_nodes, resolve_text, run_prompt_components, + save_setting, ) from utils.prompt_pair_resolver import resolve_text_input from utils.pair_execution import clear_pair_execution @@ -127,10 +133,14 @@ def queued_prompt_runs(graph: Any) -> List[Run]: """(hash, text) of each positive PromptManager node whose text is knowable. Each hash once: the same text in two positive nodes is one run of the prompt. + A node whose switch is off is never counted; one whose switch is wired from + a node the graph cannot read counts itself when it executes (#90). """ runs: List[Run] = [] seen = set() for node_id, field in run_prompt_components(graph): + if save_setting(graph, node_id) is not True: + continue node = graph.get(node_id, {}) if isinstance(graph, dict) else {} text = ( resolve_text_input(graph, node_id, field) @@ -156,6 +166,8 @@ def queued_pair_runs(graph: Any) -> List[Tuple[str, str, str]]: """Resolve raw paired recipes only when both outputs form the used pair.""" found, seen = [], set() for node_id in intrinsic_pair_nodes(graph): + if save_setting(graph, node_id) is not True: + continue # wired switch: the node counts itself (#90) positive = resolve_text_input(graph, node_id, "text") negative = resolve_text_input(graph, node_id, "negative_text") if positive is None or not positive.strip() or negative is None: diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index 51c1bdca..706c9d78 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -6,6 +6,7 @@ const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/ const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js')); const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js')); const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js')); +const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js')); const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); // ============================================================================ @@ -525,6 +526,8 @@ app.registerExtension({ if (originalConfigure) { originalConfigure.call(this, data); } + // #90: an older workflow loads with Save to library on + normalizeSaveWidget(this); if (data._userHasResized) { this._userHasResized = data._userHasResized; } diff --git a/web/js/focused-node.js b/web/js/focused-node.js index 55727284..f4aae5a1 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -2,9 +2,13 @@ import {createScope, createButton, createField, mountTheme} from './shared/toolk import {createPromptLibrary} from './prompt-library.js'; import {attachMetadataAutocomplete} from './metadata-autocomplete.js'; import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js'; +import {SAVE_WIDGET, normalizeSaveWidget, placeSaveWidgetLast, saveWidget, savesToLibrary, setSaveToLibrary} from './save-to-library.js'; const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text']; let nextEditorId = 0; +// The editor draws these widgets itself; the host keeps them as hidden, zero-height values. +function hiddenWidgetSize() { return [0, -4]; } +function hideNativeWidget(widget) { widget.hidden = true; widget.type = 'hidden'; widget.computeSize = hiddenWidgetSize; } function loadStyles() { const href = new URL('./prompt-editor.css', import.meta.url).href; @@ -43,10 +47,15 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo for (const name of pair ? [...names,'negative_text'] : names) { let widget = this.widgets?.find(item => item.name === name); if (!widget) widget = this.addWidget('text', name, '', () => {}, {}); - widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4]; + hideNativeWidget(widget); widgets.set(name, widget); } exposeTextSockets(this, textInputs); + // #90: the switch follows the hidden fallbacks above (one widget order on every frontend) and is drawn in the + // toolbar, like the other hidden widgets are drawn by the editor. + placeSaveWidgetLast(this); + const nativeSave = saveWidget(this); + if (nativeSave) hideNativeWidget(nativeSave); const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}${pair ? ' pm-pair' : ''}`; element.setAttribute('aria-label',pair ? 'Paired prompt editor' : split ? 'Split prompt editor' : 'Focused prompt editor'); loadStyles(); mountTheme(element, app.ui?.settings, scope); @@ -58,6 +67,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo toolbar.append(web); const save = pair ? createButton('Save pair','ca-primary') : null; if (save) toolbar.append(save); + const saveSwitch = document.createElement('input'); saveSwitch.type = 'checkbox'; saveSwitch.dataset.saveToLibrary = ''; + const saveLabel = document.createElement('label'); saveLabel.className = 'pm-editor-save'; + saveLabel.title = 'Off: runs of this node are not saved, counted or linked to their images. The output is unchanged.'; + saveLabel.append(saveSwitch, document.createTextNode('Save to library')); + if (nativeSave) toolbar.append(saveLabel); let saving = false; const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace'; const editor = document.createElement('div'); editor.className = 'pm-editor-compose'; @@ -138,7 +152,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo } const composed = disclosure('preview','Composed text preview'); const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview); - const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); footer.textContent = pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.'; + const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); + const SAVE_OFF = 'Save to library is off: runs of this node are not added to the library.'; + const SAVE_LINKED = 'Save to library comes from a link: runs are added to the library only while it is on.'; + function runNote(saved) { return isConnected(SAVE_WIDGET) ? SAVE_LINKED : savesToLibrary(thisNode) ? saved : SAVE_OFF; } + footer.textContent = runNote(pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.'); const saveConnectionNotice = pair ? document.createElement('p') : null; if (saveConnectionNotice) { saveConnectionNotice.className = 'pm-editor-status'; saveConnectionNotice.setAttribute('role','status'); @@ -166,6 +184,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text'); if (negative) { negative.field.hidden = Boolean(isConnected('negative_text')); negative.note.hidden = !isConnected('negative_text'); } if (save) { save.disabled = saving || hasConnectedPairValues(); saveConnectionNotice.hidden = !hasConnectedPairValues(); } + saveSwitch.checked = savesToLibrary(thisNode); saveSwitch.disabled = isConnected(SAVE_WIDGET); controls.forEach((input,name) => { input.value = value(name); if (name !== 'text' && name !== 'negative_text') { @@ -185,7 +204,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo write('category',prompt.category || ''); write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || ''); } - render(); footer.setAttribute('role','status'); footer.textContent = pair ? 'Loaded pair. Saved when this node runs.' : 'Loaded prompt. Saved when this node runs.'; main.focus(); + render(); footer.setAttribute('role','status'); footer.textContent = runNote(pair ? 'Loaded pair. Saved when this node runs.' : 'Loaded prompt. Saved when this node runs.'); main.focus(); }}); workspace.append(library.element); element.append(footer); function snapshot() { @@ -197,7 +216,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo 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=pair?'The pair is saved when this node runs.':'Main prompt is saved when this node runs.';})); + controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent=runNote(pair?'The pair is saved when this node runs.':'Main prompt is saved when this node runs.');})); + scope.listen(saveSwitch,'change',() => { + if (isConnected(SAVE_WIDGET)) return; + setSaveToLibrary(thisNode, saveSwitch.checked); render(); + footer.setAttribute('role','status'); footer.textContent = runNote(pair ? 'The pair is saved when this node runs.' : 'Main prompt is saved when this node runs.'); + thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true); + }); scope.listen(browse,'click',() => { library.element.hidden = !library.element.hidden; library.closeCompletions(); @@ -248,6 +273,8 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo const result = configure?.apply(this,args); // A saved workflow restores its inputs with their widget bindings; keep the text inputs as sockets. exposeTextSockets(this, textInputs); + // #90: an older workflow loads with Save to library on + normalizeSaveWidget(this); this._focusedView?.restore(); return result; }; diff --git a/web/js/prompt-editor.css b/web/js/prompt-editor.css index ba6fbf4a..27c78330 100644 --- a/web/js/prompt-editor.css +++ b/web/js/prompt-editor.css @@ -2,6 +2,8 @@ .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-editor-save { display: inline-flex; align-items: center; gap: 6px; color: var(--ca-muted); cursor: pointer; } +.pm-editor-save:has(input:checked) { color: inherit; } .pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; 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; } diff --git a/web/js/save-to-library.js b/web/js/save-to-library.js new file mode 100644 index 00000000..fdd39fe3 --- /dev/null +++ b/web/js/save-to-library.js @@ -0,0 +1,47 @@ +// #90: a PromptManager node's `save_to_library` switch. Off: PromptManager ignores the node's runs (nothing saved, +// counted or linked); the node's output is unchanged. +// +// The input is declared last, so it takes the widget index an older workflow used for the editor's own +// (non-serialized) DOM widget, where it stored `''` or nothing. ComfyUI coerces a BOOLEAN with `bool(value)`, so that +// `''` would turn saving off: after a workflow loads, anything that is not a boolean is the default, on. + +export const SAVE_WIDGET = 'save_to_library'; + +export function saveWidget(node) { + return node?.widgets?.find(widget => widget?.name === SAVE_WIDGET); +} + +/** Whether the node saves its runs (a missing widget, as on an older backend, is on). */ +export function savesToLibrary(node) { + return saveWidget(node)?.value !== false; +} + +/** Set a non-boolean switch value (from an older workflow) to on. Returns whether it changed. */ +export function normalizeSaveWidget(node) { + const widget = saveWidget(node); + if (!widget || typeof widget.value === 'boolean') return false; + widget.value = true; + const index = node.widgets.indexOf(widget); + if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = true; + return true; +} + +/** Move the switch after every other widget the node has now, so its index is the same on every frontend. */ +export function placeSaveWidgetLast(node) { + const widget = saveWidget(node); + const widgets = node?.widgets; + if (!widget || widgets.at(-1) === widget) return false; + widgets.splice(widgets.indexOf(widget), 1); + widgets.push(widget); + return true; +} + +/** Turn the switch on or off the way a native widget edit does. */ +export function setSaveToLibrary(node, value) { + const widget = saveWidget(node); + if (!widget) return; + widget.value = Boolean(value); + const index = node.widgets.indexOf(widget); + if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = widget.value; + widget.callback?.(widget.value, undefined, node); +}